組み込みフィクスチャ — tmp_path、capsys、caplog
ファイル、標準出力、ログなど、テストが難しい部分のためにpytestが標準提供しているフィクスチャを学びます。実際のパスに触れることなくCSVやJSONを書き出すコードをテストし、出力やログの内容を検証しましょう。
どうやって学びますか?
解決する問題
以下は、CSVファイルを書き出す関数とそのテストです:
import csv
def write_csv(path, rows):
with open(path, "w", newline="") as f:
writer = csv.writer(f)
writer.writerow(["product", "quantity"])
writer.writerows(rows)
def test_write_csv():
write_csv("report.csv", [("pen", 12), ("bag", 2)])
with open("report.csv") as f:
assert f.read().splitlines()[1] == "pen,12"$ pytest -q
. [100%]
1 passed in 0.08s
$ ls
report.csv test_report.pyテストはパスしましたが、ゴミが残ってしまいました。プロジェクト内のコードのすぐ隣にreport.csvが置かれています。別のフォルダからpytestを実行すると、ファイルはそちらに作成されます。"report.csv"という指定が、コマンドを実行した場所からの相対パスになっているからです。どちらもreport.csvに書き込む2つのテストがあると、互いの残骸を読み取ってしまいます。そして、もしプロジェクトに本物のreport.csvがすでに存在していた場合、テストによって上書きされてしまいます。
テストは、実行前の世界をそのまま元通りに残しておくべきです。ファイルはその典型例ですが、画面への出力やログメッセージも、反対の視点から同じ問題を投げかけます。つまり、関数が何かを外部へ出力し、テストはそれを捉える必要があるということです。
過去2つの章では、フィクスチャの書き方を見てきました。本章では、こうした厄介な部分をテストするためにpytestが標準で提供しているフィクスチャを扱います。名前を指定して要求するだけで利用できます。
この章を終える頃には
tmp_pathを使って各テストに専用の空フォルダを与え、CSVやJSONを書き出す関数をテストできるようになるtmp_path_factoryを使ってセッション全体で1つの一時フォルダを共有できるようになるcapsysを使ってプログラムが出力する内容をテストし、代わりにcapfdが必要になる状況を判断できるようになるcaplogを使ってプログラムが記録するログをテストし、WARNING未満のメッセージも扱えるようになるrequest.node.nameを使って実行中のテストを識別できるようになる- これらのフィクスチャを誤って使用した際に出力されるエラーを読み解けるようになる
前提条件: フィクスチャのスコープとconftest.py。
テストを書く前に
ファイルや端末、ログを操作するコードには副作用(side effects)があります。つまり、関数の本当の結果は戻り値だけでなく、外部の世界に残すものにもあるということです。そのため、テストコードを書く前に、各副作用がどこに向かうのかを書き出しておきましょう。
1. すべての出力を洗い出す。 在庫行をエクスポートする関数の場合、最大で4つの出力があります:
- 戻り値(書き込んだパス)
- ファイルの内容
- 出力されるテキスト(標準出力と標準エラー出力に分割)
- ログに記録される内容とそのレベル
これらはそれぞれ独立した保証事項(コントラクト)であり、それぞれを観測する手段が必要です。戻り値は通常どおり取得できます。残りの3つに対して、pytestにはフィクスチャが用意されています。ファイルにはtmp_path、出力テキストにはcapsys、ログにはcaplogです。
2. コード側でファイルの出力先をテストから選択できるようにする。 これは最初に確認すべき問いです。もし答えが「ノー」であれば、どんなフィクスチャを使っても救われないからです。内部でパスを自ら組み立てる関数 — open("/var/reports/stock.csv", "w") — は、その実際の場所に書き込むことでしかテストできません。フォルダやパスをパラメータとして受け取る関数であれば、tmp_pathを指すように指定できます。自分でコードを変更できるのであれば、パスを受け取るように変更してください(変更できない場合は、次章で学ぶmonkeypatchが回避策となります)。
3. 環境を確認する。 新たにインストールするものはありません。本章のすべてのフィクスチャはpytestに標準で同梱されています。必要なのはいつもの準備です。pytestがインストールされた仮想環境と、テストファイルからインポート可能なテスト対象コードを用意します。また、caplog.set_levelで特定のロガーを対象にできるよう、使用しているロガーの名前(logging.getLogger("export")など)を把握しておきましょう。
4. テストケースを計画する。 有効な行をstock.csvに書き出し、負の数量に対して警告をログ出力し、サマリーを出力するexport_stock(rows, out_dir)の場合:
| ケース | 入力 | 期待される結果 | |---|---|---| | 正常系(happy path) | [("pen", 12), ("eraser", 30)]、tmp_path | ファイルにヘッダー + 2行、wrote 2 rows to stock.csvを出力 | | 無効な行 | 数量が-1の行が1つ | その行は除外され、そのアイテム名を挙げたWARNINGが1件記録される | | 存在しないフォルダ | tmp_path / "exports" / "2026" | フォルダが作成され、その中にファイルが配置される | | 有効な行がない | [("bag", -1)] | ヘッダーのみ、標準エラー出力にnothing exported | | クリーンな出力 | 任意の有効な行 | 標準エラー出力には何も出力されない |
5. テストすべきでないことを決める。 Pythonのcsvモジュールがカンマを正しくクォートするかどうかや、json.dumpsが有効なJSONを生成するかどうかはテストしません。これらは他者が提供する保証事項であり、すでに徹底的にテストされています。コードを編集するたびに変化するファイル名や行番号が含まれるため、caplog.textの全体に対してアサーションを行うのも避けましょう。また、tmp_pathの正確な配置場所もテストしません。マシンごとに異なるからです。テストすべきはあなた自身の設計判断です。どの行を残すか、何がどこに出力されるか、メッセージに何と書かれているかをテストします。
本章の残りの部分では、上記の表の各列に対応するツールを解説し、完全な例でこれらを実装します。
tmp_path — テストごとに新しいフォルダ
tmp_pathを要求すると、pytestは直前まで存在しなかったディレクトリをテストに渡してくれます:
def test_first(tmp_path):
print(tmp_path)
print(type(tmp_path))
def test_second(tmp_path):
print(tmp_path)$ pytest -q -s
/tmp/pytest-of-you/pytest-0/test_first0
<class 'pathlib.PosixPath'>
./tmp/pytest-of-you/pytest-0/test_second0
.
2 passed in 0.07s(-sはpytest独自の出力キャプチャを無効化し、printの呼び出しがターミナルに表示されるようにするフラグです。Windowsの場合、パスはユーザーのTempフォルダ以下のどこかで始まり、型はWindowsPathになります。)
この出力から読み取るべきポイントは3つあります:
- 文字列ではなく
pathlib.Pathであること。/でパスを結合し、.read_text()で読み取り、.exists()で存在を確認します。 - 各テストがテスト名にちなんだ専用のフォルダを受け取ること。
test_first0とtest_second0は決して交わらないため、両方のテストがreport.csvという名前のファイルを作成しても問題ありません。 - テストの実行ごとに新しいベースフォルダが作成されること。
pytest-0、次にpytest-1、というように進みます。pytestは直近3回分の実行フォルダを保持するため、テスト失敗後に内部を確認できます。それより古いものはpytest自身が削除するため、手動でクリーンアップする必要はありません。
ファイルを書き出す関数のテスト
先ほどのCSVテストを適切に書き直してみましょう。また、設定の保存はほぼすべてのプログラムに共通するファイル書き込み処理であるため、JSONを扱う2つの関数も追加します:
import csv
import json
def write_csv(path, rows):
with open(path, "w", newline="") as f:
writer = csv.writer(f)
writer.writerow(["product", "quantity"])
writer.writerows(rows)
def save_settings(path, settings):
path.write_text(json.dumps(settings, indent=2))
def load_settings(path):
return json.loads(path.read_text())
def test_write_csv(tmp_path):
target = tmp_path / "report.csv"
write_csv(target, [("pen", 12), ("bag", 2)])
assert target.exists()
assert target.read_text().splitlines() == [
"product,quantity",
"pen,12",
"bag,2",
]
def test_csv_reads_back(tmp_path):
target = tmp_path / "report.csv"
write_csv(target, [("pen", 12)])
with target.open(newline="") as f:
rows = list(csv.DictReader(f))
assert rows == [{"product": "pen", "quantity": "12"}]
def test_settings_round_trip(tmp_path):
target = tmp_path / "settings.json"
settings = {"currency": "BDT", "tax": 0.15}
save_settings(target, settings)
assert load_settings(target) == settings
assert '"currency": "BDT"' in target.read_text()$ pytest -q
... [100%]
3 passed in 0.07s
$ ls
test_files.py今回は何も残りませんでした。パターンは常に同じです。tmp_path内にパスを構築し、それをテスト対象のコードに渡し、ファイルを読み戻して検証します。
検証方法には2通りあり、上記のテストでは両方を使用しています。test_write_csvは完全一致のテキストを検証します。これは最も厳格なテストであり、別のプログラムがファイルを読み取る場合に適しています。一方、test_csv_reads_backとtest_settings_round_tripは実際の読み取り処理と同じ方法でファイルを読み戻し、データを比較します。DictReaderが文字列の"12"を返している点に注目してください。CSVには数値がなく、テキストしか存在しません。ラウンドトリップテスト(書き出して読み戻すテスト)は、こうした予期せぬ挙動を発見するのに役立ちます。
tmp_pathは空の状態で始まります。コードがサブフォルダの存在を前提としている場合は、テスト内で(tmp_path / "exports").mkdir()として作成するか、あるいはより望ましい方法としてコード側で作成するようにします。誰もフォルダを作成しなかった場合に何が起きるかは、「問題が起きたとき」の節で確認します。
tmp_path_factory — セッション全体で1つのフォルダ
tmp_pathは関数スコープであり、テストごとに新しいフォルダが作成されます。しかし、時にはそれが無駄になることもあります。たとえば、作成に時間のかかる大きなサンプルファイルを複数のテストが読み取る場合を考えてみましょう。前章で学んだ知識があればscope="session"を使いたくなるところですが、セッションフィクスチャでtmp_pathを使うことはできません。セッション全体で生存するフィクスチャが、各テストの後に破棄されるものに依存することはできないからです。
そこで登場するのが、自身がセッションスコープであるtmp_path_factoryです。mktempを使ってフォルダを要求します:
import pytest
@pytest.fixture(scope="session")
def big_catalogue(tmp_path_factory):
folder = tmp_path_factory.mktemp("data")
target = folder / "catalogue.csv"
lines = ["product,quantity"]
lines += [f"item{i},{i}" for i in range(10_000)]
target.write_text("\n".join(lines))
print("built", target)
return target
def test_has_header(big_catalogue):
assert big_catalogue.read_text().startswith("product,quantity")
def test_has_every_row(big_catalogue):
assert len(big_catalogue.read_text().splitlines()) == 10_001$ pytest -q -s
built /tmp/pytest-of-you/pytest-6/data0/catalogue.csv
..
2 passed in 0.07s2つのテストに対して、builtは1度しか表示されません。mktemp("data")は名前に番号を付加します(1回目はdata0、再度呼び出すとdata1)。そのため、2回の呼び出しが衝突することはありません。
トレードオフは前章で見たものと同じです。両方のテストが1つのファイルを共有することになります。ここでは読み取りしか行わないため問題ありません。共有ファイルを変更するテストには、tmp_pathを使って個別のコピーを用意する必要があります。
capsys — プログラムが出力する内容のテスト
ほぼ誰もが一度は犯す間違いから始めましょう。挨拶を出力する関数があり、テストでその結果を検証しようとします:
def greet(name):
print(f"hello, {name}")
def test_greet():
assert greet("Rina") == "hello, Rina"$ pytest -q
F [100%]
=================================== FAILURES ===================================
__________________________________ test_greet __________________________________
def test_greet():
> assert greet("Rina") == "hello, Rina"
E AssertionError: assert None == 'hello, Rina'
E + where None = greet('Rina')
test_wrong.py:6: AssertionError
----------------------------- Captured stdout call -----------------------------
hello, Rina
=========================== short test summary info ============================
FAILED test_wrong.py::test_greet - AssertionError: assert None == 'hello, Rina'
1 failed in 0.09sprintはテキストを送信するだけであり、返すわけではないため、greetはNoneを返します。しかし、レポートの末尾を見てください。Captured stdout callとあり、pytestはすでにテキストをキャプチャしています。pytestはターミナルをすっきりと保つために、すべてのテストでこれを行っています。このキャプチャされた出力を手に入れるための手段がcapsysです:
def greet(name):
print(f"hello, {name}")
def test_greet(capsys):
greet("Rina")
assert capsys.readouterr().out == "hello, Rina\n"$ pytest -q
. [100%]
1 passed in 0.07s先に関数を呼び出し、その後に何が出力されたかを読み取ります。実際のコマンドラインプログラムは複数行を出力し、2つのストリームに書き込み、終了コードを返します。以下はその例とテストです:
import sys
def main(argv):
if not argv:
print("usage: stock ITEM QUANTITY", file=sys.stderr)
return 2
item, quantity = argv[0], int(argv[1])
print(f"{item}: {quantity} in stock")
if quantity < 5:
print("low stock")
return 0
def test_prints_the_stock(capsys):
code = main(["pen", "12"])
captured = capsys.readouterr()
assert code == 0
assert captured.out == "pen: 12 in stock\n"
assert captured.err == ""
def test_warns_when_low(capsys):
main(["bag", "2"])
out = capsys.readouterr().out
assert out.splitlines() == ["bag: 2 in stock", "low stock"]
def test_usage_goes_to_stderr(capsys):
code = main([])
captured = capsys.readouterr()
assert code == 2
assert captured.out == ""
assert "usage:" in captured.err$ pytest -q
... [100%]
3 passed in 0.08scapsys.readouterr()は、前回読み取って以降に標準出力と標準エラー出力に書き込まれたすべての内容を、.outと.errの2つの文字列フィールドを持つオブジェクトとして返します。これはnamedtupleであるため、out, err = capsys.readouterr()とアンパックすることも可能です。
実践で重要となる詳細が2点あります:
printは改行を追加します。 キャプチャされるテキストは"pen: 12 in stock"ではなく、"pen: 12 in stock\n"です。複数行の場合は、.splitlines()を使うときれいなリストが得られ、比較しやすくなります。- エラーは
errに出力されます。 使用方法(usage)のメッセージは標準エラー出力に出力されるべきものであり、test_usage_goes_to_stderrはメッセージがそこに出力されたことと、標準出力には何も出力されなかったことの両方を検証しています。
「前回読み取って以降に」というのは文字通りの意味であり、読み取るとバッファは空になります:
def test_read_twice(capsys):
print("first")
one = capsys.readouterr()
print("second")
two = capsys.readouterr()
assert one.out == "first\n"
assert two.out == "second\n"$ pytest -q
. [100%]
1 passed in 0.07sこれは出力内容を段階的に確認できるため非常に便利です。一方で、意図せず2回目のreadouterr()を呼び出した際に空の結果が返ってくる原因にもなります。
capfd — 出力がPythonを経由しない場合
capsysはPythonのsys.stdoutとsys.stderrを置き換えます。子プロセスやC拡張など、オペレーティングシステムの出力に直接書き込むものは、この仕組みを迂回してしまいます。capfdは1つ下のレイヤーであるファイルディスクリプタのレベルでキャプチャを行うため、そうした出力も捉えることができます:
import os
def test_with_capsys(capsys):
os.system("echo from the shell")
assert capsys.readouterr().out == ""
def test_with_capfd(capfd):
os.system("echo from the shell")
assert capfd.readouterr().out == "from the shell\n"$ pytest -q
.. [100%]
2 passed in 0.07s使い方は同じで、readouterr()を呼び出して.outや.errを取得します。基本はcapsysを使い、Python外部からの出力がキャプチャされない場合にのみcapfdに切り替えましょう。
caplog — プログラムが記録するログのテスト
実際のプログラムでは、標準出力への出力ではなくログ記録を行います。ログに出力される警告も他の機能と同様にプログラムの振る舞いの一部であり、テストする価値があります:
import logging
logger = logging.getLogger("importer")
def parse_quantities(lines):
result = {}
for line in lines:
item, _, raw = line.partition(",")
if not raw.strip().isdigit():
logger.warning("skipped %r: bad quantity", item)
continue
result[item] = int(raw)
logger.info("parsed %d of %d lines", len(result), len(lines))
return result
def test_bad_row_is_logged(caplog):
result = parse_quantities(["pen,12", "bag,two"])
assert result == {"pen": 12}
assert len(caplog.records) == 1
record = caplog.records[0]
assert record.levelname == "WARNING"
assert record.getMessage() == "skipped 'bag': bad quantity"
def test_text_is_one_string(caplog):
parse_quantities(["bag,two"])
print(repr(caplog.text))
assert "bad quantity" in caplog.text$ pytest -q -s
."WARNING importer:test_importer.py:11 skipped 'bag': bad quantity\n"
.
2 passed in 0.07scaplogは同じログ情報を3種類の方法で提供します:
caplog.records— メッセージごとに1つのlogging.LogRecordオブジェクトが格納されたリスト。各レコードには.levelname("WARNING")、.name(ロガー名、"importer")、そして%rに実際の値が埋め込まれた文字列を返す.getMessage()があります。caplog.messages— 完成したメッセージ文字列のみのリスト。caplog.text— すべてのログが整形された1つの文字列。in演算子を使って手早く含まれているか確認するのには向いていますが、厳密な検証には緩すぎます。
ログレベルが重要な場合はrecordsを使い、メッセージの文言のみを確認したい場合はmessagesを使うのが適しています。
WARNING未満のメッセージはデフォルトではキャプチャされない
parse_quantitiesはINFOレベルのサマリーもログに出力しています。これに対するテストを追加してみましょう:
def test_summary_is_logged(caplog):
parse_quantities(["pen,12"])
assert caplog.messages == ["parsed 1 of 1 lines"]$ pytest -q -k summary
F [100%]
=================================== FAILURES ===================================
____________________________ test_summary_is_logged ____________________________
caplog = <_pytest.logging.LogCaptureFixture object at 0x70495b4f43e0>
def test_summary_is_logged(caplog):
parse_quantities(["pen,12"])
> assert caplog.messages == ["parsed 1 of 1 lines"]
E AssertionError: assert [] == ['parsed 1 of 1 lines']
E
E Right contains one more item: 'parsed 1 of 1 lines'
E Use -v to get more diff
test_importer.py:38: AssertionError
=========================== short test summary info ============================
FAILED test_importer.py::test_summary_is_logged - AssertionError: assert [] =...
1 failed, 2 deselected in 0.08s何もキャプチャされませんでした。関数は確かにログを出力しましたが、PythonのloggingはデフォルトでWARNINGから始まるため、INFOメッセージはcaplogに届く前に破棄されてしまったのです。テスト用にcaplog.set_levelを使ってレベルを下げましょう:
import logging
def test_summary_is_logged(caplog):
caplog.set_level(logging.INFO)
parse_quantities(["pen,12"])
assert caplog.messages == ["parsed 1 of 1 lines"]
def test_only_the_importer_logger(caplog):
caplog.set_level(logging.INFO, logger="importer")
parse_quantities(["pen,12", "bag,two"])
assert [r.levelname for r in caplog.records] == ["WARNING", "INFO"]$ pytest -q
.. [100%]
2 passed in 0.07sログを出力するコードを実行する前にset_levelを呼び出します。2つ目の形式のようにlogger="importer"を指定すると、そのロガーのレベルのみが下がるため、出力の多い外部ライブラリを静かなままに保つことができます。どちらの形式であっても、テストが終了するとpytestが以前のレベルに戻してくれます。
requestとpytestconfig
他のフィクスチャの内部でよく利用される組み込みフィクスチャがもう1つあります。requestはフィクスチャを要求したテストの情報を保持しており、request.node.nameはそのテストの名前です。これを使うと、読みやすく一意なファイル名を付けるのに役立ちます:
import pytest
@pytest.fixture
def export_file(tmp_path, request):
return tmp_path / f"{request.node.name}.csv"
def test_pens(export_file):
print(export_file.name)
@pytest.mark.parametrize("quantity", [1, 50])
def test_bulk(export_file, quantity):
print(export_file.name)
def test_config(pytestconfig):
print(pytestconfig.rootpath.name, pytestconfig.getoption("verbose"))$ pytest -q -s
test_pens.csv
.test_bulk[1].csv
.test_bulk[50].csv
.shop -1
.
4 passed in 0.07sパラメータ化されたテストの名前には、角括弧でパラメータが含まれます。また、最後のテストではpytestconfigを取り上げています。これは今回のテスト実行の設定オブジェクトであり、プロジェクトのルートフォルダや各コマンドラインオプション(-qは詳細度(verbosity)を-1に設定します)が含まれています。これについては設定やプラグインの章で再び詳しく学びます。
完全な例
export.pyは有効な在庫行をCSVに書き出し、スキップした各行に対して警告をログ出力し、サマリーを出力します:
import csv
import logging
import sys
logger = logging.getLogger("export")
def export_stock(rows, out_dir):
"""Write the valid rows to out_dir/stock.csv and return its path."""
out_dir.mkdir(parents=True, exist_ok=True)
target = out_dir / "stock.csv"
written = 0
with target.open("w", newline="") as f:
writer = csv.writer(f)
writer.writerow(["product", "quantity"])
for product, quantity in rows:
if quantity < 0:
logger.warning("skipped %s: negative quantity %d", product, quantity)
continue
writer.writerow([product, quantity])
written += 1
print(f"wrote {written} rows to {target.name}")
if written == 0:
print("nothing exported", file=sys.stderr)
return targettest_export.pyは、ファイル、ログ、ターミナルの3つの出力すべてを検証します:
import csv
from export import export_stock
ROWS = [("pen", 12), ("bag", -1), ("eraser", 30)]
def read_rows(path):
with path.open(newline="") as f:
return list(csv.reader(f))
def test_writes_only_valid_rows(tmp_path):
target = export_stock(ROWS, tmp_path)
assert target == tmp_path / "stock.csv"
assert read_rows(target) == [
["product", "quantity"],
["pen", "12"],
["eraser", "30"],
]
def test_creates_missing_folders(tmp_path):
out_dir = tmp_path / "exports" / "2026"
target = export_stock(ROWS, out_dir)
assert target.parent == out_dir
assert target.exists()
def test_skipped_row_is_logged(tmp_path, caplog):
export_stock(ROWS, tmp_path)
assert [r.levelname for r in caplog.records] == ["WARNING"]
assert caplog.messages == ["skipped bag: negative quantity -1"]
def test_prints_a_summary(tmp_path, capsys):
export_stock(ROWS, tmp_path)
captured = capsys.readouterr()
assert captured.out == "wrote 2 rows to stock.csv\n"
assert captured.err == ""
def test_empty_export_warns_on_stderr(tmp_path, capsys):
export_stock([("bag", -1)], tmp_path)
out, err = capsys.readouterr()
assert out == "wrote 0 rows to stock.csv\n"
assert err == "nothing exported\n"$ pytest -v
collecting ... collected 5 items
test_export.py::test_writes_only_valid_rows PASSED [ 20%]
test_export.py::test_creates_missing_folders PASSED [ 40%]
test_export.py::test_skipped_row_is_logged PASSED [ 60%]
test_export.py::test_prints_a_summary PASSED [ 80%]
test_export.py::test_empty_export_warns_on_stderr PASSED [100%]
============================== 5 passed in 0.09s ===============================
$ ls
export.py test_export.py注目すべき点が3つあります。
第1に、export_stockがフォルダをパラメータとして受け取っている点です。その1つの判断こそが、この関数をテスト可能にしている理由です。本番プログラムは実際のフォルダを渡し、テストはtmp_pathを渡します。内部に"/var/reports/stock.csv"をハードコードしている関数は、その実際のパスに書き込むことでしかテストできません — これはテストが決して行ってはならないことです。
第2に、各テストが自身が検証するフィクスチャのみを要求している点です。そのうち2つのテストではtmp_pathとcapsysやcaplogを組み合わせています。組み込みフィクスチャ同士や、自作のフィクスチャと自由に組み合わせて使うことができます。
第3に、ファイルを書き出すテストが5つ実行された後でも、カレントディレクトリは全く汚れていない点です。
問題が起きたとき
fixture 'tmp_dir' not found パラメータ名のタイプミスです。pytestはフィクスチャを完全一致の名前で照合します。このエラーの続きにはavailable fixtures:として、pytestが認識しているすべてのフィクスチャ(tmp_path、tmp_path_factory、capsys、capfd、caplogなど)が表示されます。そのリストから正しい名前をコピーしてください。(リストにはtmpdirも表示されますが、これはpathlib.Pathではなくpy.pathオブジェクトを返す古いバージョンです。新しいコードではtmp_pathを使用してください。)
ScopeMismatch: You tried to access the function scoped fixture tmp_path with a session scoped request object. scope="session"(または"module")のフィクスチャがtmp_pathを要求しています。生存期間の長いフィクスチャが、テストごとに破棄されるフィクスチャに依存することはできません。代わりにtmp_path_factory.mktemp("name")を使用してください。
FileNotFoundError: [Errno 2] No such file or directory: '/tmp/pytest-of-you/pytest-3/test_nested0/exports/report.csv' tmp_path自体は存在しますが、その中のexportsフォルダが存在しません。フォルダは自動では作成されません。export_stockが行っているように、親ディレクトリに対して.mkdir(parents=True, exist_ok=True)を最初に呼び出してください(理想的にはテスト対象コード内で行います)。
ファイルが/tmp/pytest-of-you/pytest-5/test_str0report.csvに作成される エラーは一切発生しませんが、パスが文字列で構築されています(str(tmp_path) + "report.csv")。パスの区切り文字が抜けてしまったため、ファイルがテストのフォルダの内部ではなく隣に作成されてしまいました。Pathオブジェクト自体に対して/を使って結合してください(tmp_path / "report.csv")。
caplogでAssertionError: assert [] == ['parsed 1 of 1 lines']が発生する メッセージがWARNING未満のレベルでログ出力され、キャプチャされませんでした。コードを実行する前に、caplog.set_level(logging.INFO)(またはlogging.DEBUG)を呼び出してください。
テキストを期待していたのにcapsys.readouterr().outが""になる 同じテスト内のより前の箇所ですでにreadouterr()によって出力が消費されてしまったか、あるいはサブプロセスやos.systemの呼び出しのようにPythonのsys.stdoutを経由しない出力であったかのどちらかです。後者の場合はcapfdに切り替えてください。
cannot use capfd and capsys at the same time 1つのテストが両方のフィクスチャを要求しています。これらは異なるレイヤーで同じストリームをキャプチャするものです。どちらか一方を選択してください。
ステップ 4 / 6 — 予想する
理解度をチェック
最後の行の実行後、captured.outには正確に何が入っていますか?
def test_twice(capsys):
print("first")
capsys.readouterr()
print("second")
print("third")
captured = capsys.readouterr()- A'second\nthird\n'
- B'third\n'
- C'first\nsecond\nthird\n'
- D''
restockは実際にログを出力しているにもかかわらず、テストはassert [] == ['restocked pen']で失敗します。なぜでしょうか?
import logging
logger = logging.getLogger("shop")
def restock(item):
logger.info("restocked %s", item)
def test_restock_is_logged(caplog):
restock("pen")
assert caplog.messages == ["restocked pen"]- A`%s`が埋め込まれないため、メッセージが`'restocked %s'`になる
- B`caplog.messages`にはレベル名が含まれるため、`'INFO restocked pen'`と比較する必要がある
- C`INFO`はデフォルトの`WARNING`レベルより下であるため何もキャプチャされなかった。まず`caplog.set_level(logging.INFO)`を呼び出す必要がある
- Dロガーの名前が`"shop"`であり、`caplog`はルートロガーのみを監視するため
scope="session"のフィクスチャが、大きなサンプルファイルを一度だけ作成したいと考えています。ファイルはどこに配置すべきですか?
- A`tmp_path_factory.mktemp("data")`が返すフォルダの中
- Bプロジェクト内の`data/`フォルダの中 — 次の実行まで残るようにするため
- C`tmp_path`の中 — フィクスチャのパラメータで要求するだけでよい
- D`tmpdir`の中 — `tmp_path`のセッション版であるため
回答にはアカウントが必要です
ログインして答えを確認
問題は上にあります。頭の中で答えを考えることがいちばん大切です。ログインすると、正解・解説・3 段階のヒントを確認できます。
やってみよう
{"title": "Buy pens", "done": False}のような辞書のリストを受け取る関数export_notes(notes, out_dir)を持つモジュールnotes.pyを作成してください:
out_dirが存在しない場合は作成し、out_dir / "notes.json"にメモを書き出す- タイトルが空のメモに対して
WARNINGをログ出力し、そのメモをファイルから除外する "exported N notes"というINFOメッセージをログ出力する- 処理の完了時に
saved notes.jsonを出力する
次に、組み込みフィクスチャのみを使用して以下を検証するtest_notes.pyを作成してください:
- JSONファイルを読み戻した内容が、有効なメモと完全に一致すること
tmp_path / "a" / "b"のような存在しないサブフォルダが作成されることlevelname == "WARNING"として警告がログ出力され、INFOメッセージもキャプチャされること(最初に何を呼び出す必要がありますか?)- 正確に
saved notes.json\nが出力され、標準エラー出力には何も出力されないこと
pytest -qを実行し、その後にls(Windowsではdir)を実行してください。プロジェクトフォルダ内にnotes.jsonが作成されている場合、テストまたは関数のいずれかが実際のパスに書き込みを行っています。その問題を見つけて修正する習慣こそが、本章の狙いです。
解答例
まずは「テストを書く前に」の表に従って計画を立てます:
| ケース | 入力 | 期待される結果 | |---|---|---| | 有効なメモが保持される | 3つのメモ(うち1つのタイトルは" ") | 残り2つのメモが変更されずにファイルに保存される | | 存在しないフォルダ | tmp_path / "a" / "b" | 両方のフォルダが作成され、その中にファイルが配置される | | 空のタイトル | 同じ3つのメモ | WARNINGが1件、その後にINFOのexported 2 notesが記録される | | 出力 | 同じ3つのメモ | 標準出力にsaved notes.json\n、標準エラー出力には何も出力されない | | エクスポート対象がない | [] | ファイルに[]が保存される |
notes.py:
import json
import logging
logger = logging.getLogger("notes")
def export_notes(notes, out_dir):
"""Write notes with a title to out_dir/notes.json and return its path."""
out_dir.mkdir(parents=True, exist_ok=True)
valid = []
for note in notes:
if not note["title"].strip():
logger.warning("skipped a note with an empty title")
continue
valid.append(note)
target = out_dir / "notes.json"
target.write_text(json.dumps(valid, indent=2))
logger.info("exported %d notes", len(valid))
print("saved notes.json")
return targettest_notes.py:
import json
import logging
from notes import export_notes
NOTES = [
{"title": "Buy pens", "done": False},
{"title": " ", "done": True},
{"title": "Call the printer", "done": True},
]
def test_file_holds_only_valid_notes(tmp_path):
target = export_notes(NOTES, tmp_path)
assert target == tmp_path / "notes.json"
assert json.loads(target.read_text()) == [NOTES[0], NOTES[2]]
def test_creates_missing_folders(tmp_path):
out_dir = tmp_path / "a" / "b"
target = export_notes(NOTES, out_dir)
assert target.parent == out_dir
assert target.exists()
def test_empty_title_is_warned_about(tmp_path, caplog):
caplog.set_level(logging.INFO, logger="notes")
export_notes(NOTES, tmp_path)
assert [(r.levelname, r.getMessage()) for r in caplog.records] == [
("WARNING", "skipped a note with an empty title"),
("INFO", "exported 2 notes"),
]
def test_prints_one_line_and_no_errors(tmp_path, capsys):
export_notes(NOTES, tmp_path)
out, err = capsys.readouterr()
assert out == "saved notes.json\n"
assert err == ""
def test_no_notes_still_writes_an_empty_list(tmp_path):
target = export_notes([], tmp_path)
assert json.loads(target.read_text()) == []$ pytest -q
..... [100%]
5 passed in 0.10s
$ ls
notes.py test_notes.py各設計判断の理由:
out_dirをパラメータにしています。 これによってすべてのテストがtmp_pathを渡せるようになり、最後のlsでプロジェクト内にnotes.jsonが残っていないことが確認できます。- JSONのテストでテキスト比較ではなく
json.loadsでファイルを読み戻しています。 保証する内容は「データとしてのメモ」であり、正確なインデントはjson.dumpsの役割であって私たちの管轄ではありません。また、NOTES[0]やNOTES[2]と比較することで、有効なメモが変更されずに書き込まれたことも証明されます。 - タイトル
" "を""ではなく空白文字にしています。 空白文字のみのタイトルは境界ケース(エッジケース)です。if not note["title"]では空でないと判定されてしまい、.strip()を使って初めて検出できます。このテストによってその仕様を確定させています。 caplog.set_levelを最初に呼び出し、"notes"を対象にしています。 これを行わないとINFOの行がキャプチャされません。(levelname, message)のペアを比較することで、レベル、文言、順序を1つのアサーションで検証できます。編集のたびにテストが壊れてしまうようなcaplog.text(ファイル名や行番号を含む)に依存することもありません。- 意図的に
err == ""をアサートしています。 「何も問題が起きなかったこと」は仕様の合意事項(コントラクト)の一部であり、そうしなければ標準エラー出力へのデバッグ用printの混入を見逃してしまう可能性があります。 - 空のリストに対して個別のテストを用意しています。 新規アカウントなどでファイルを読む側が最初に遭遇するエッジケースであり、何も書き出さないのではなく
[]を書き出すという設計判断は維持する価値があります。