バグをテストで釘付けにする
一言でいうと
運用ツールのバグは、まず失敗するテストで再現し、そのあとで直します。ファイル・時間・環境変数・出力に依存するツールは、pytestのtmp_path・monkeypatch・capsysで、その依存をテストの中に閉じ込めます。
なぜ必要なのか
ログローテーションのツールが「3つだけ残す」と言っていたのに、4つ残りました。担当者がコードを直しましたが、翌週、同じバグが別の形で戻ってきました。今度は--dry-runなのにファイルが削除されたのです。2つの事故の共通点は、直した人が直ったという証拠を残さなかったことです。証拠は、テストです。バグを再現するテストは、直す前は赤で、直したあとは緑であり、そのあとは、同じバグが戻ってくるのを防ぐゲートキーパーになります。
どう動くのか
pytestは、test_*.pyファイルのtest_*関数を見つけて実行し、assertが偽なら失敗として記録します。ツールをテストするには、ツールがインポートできる必要があります。前のモジュールでmain(argv) -> intの形にこだわった理由が、ここで戻ってきます。import rotate; rotate.main([str(d), "--keep", "3"])のように呼べば、プロセスを新しく起動しなくても、ツール全体をテストできます。
ファイル。tmp_pathフィクスチャは、テスト関数ごとに固有の一時ディレクトリを、pathlib.Pathとして渡します。その中にファイルを作ってツールを実行し、結果を見れば済み、テストが終わったら、pytestが後始末をします。本物の/var/logに触れるテストは、テストではなく事故です。
時間。「7日より古いファイル」のような条件は、time.time()に依存します。本物の時間を待つことはできないので、monkeypatchで差し替えます。monkeypatch.setattr(rotate.time, "time", lambda: 1_700_000_000)のように属性を差し替えれば、そのテストが終わるときに元に戻ります。ドキュメントは、setattr・delattr・setitem・setenv・delenv・chdirを提供すると書いていますが、すべて同じ原則です。グローバルな状態を、テストの中でだけ変更します。
出力。ツールが何を削除したかをprintで知らせるなら、その文も契約です。capsysフィクスチャのreadouterr()が、テスト中に出力されたstdoutとstderrを返します。assert "delete old.log" in captured.outのように確認します。
複数の入力。同じロジックを、keep=0・1・3・10で実行したいときに、関数を4回コピーしません。parametrizeデコレーターが、引数のリストごとにテストを1つずつ作ります。失敗すると、どの値で失敗したかが、テストID(test_keep[3])に残ります。
import pytest, rotate
@pytest.mark.parametrize("keep", [0, 1, 3, 10])
def test_keep_leaves_exactly_keep_files(tmp_path, keep):
for i in range(5):
(tmp_path / f"app-{i}.log").write_text("x")
assert rotate.main([str(tmp_path), "--keep", str(keep)]) == 0
assert len(list(tmp_path.glob("*.log"))) == min(5, keep)
def test_dry_run_deletes_nothing(tmp_path, capsys):
(tmp_path / "a.log").write_text("x")
(tmp_path / "b.log").write_text("x")
rotate.main([str(tmp_path), "--keep", "0", "--dry-run"])
assert sorted(p.name for p in tmp_path.glob("*.log")) == ["a.log", "b.log"]
assert "a.log" in capsys.readouterr().out
順序。バグの報告を受けたら、(1)再現するテストを書きます。今のコードで実行すると、失敗しなければなりません。失敗しなければ、再現が間違っていて、その状態でコードを直すと、何を直したのかわかりません。(2)コードを直します。(3)テストが通ります。(4)ほかのテストもすべて通ります。直したものが、ほかのものを壊していないという証拠です。この4つのステップのうち、(1)を飛ばすことが、「翌週に同じバグが戻ってくる」原因です。
結果を残す。CIは、人の目ではなく、ファイルを読みます。pytest --junitxml=report.xmlは、テストの数・失敗の数・所要時間をXMLとして残し、それがパイプラインの合格条件になります。終了コードもあります。すべて通れば0、1つでも失敗すれば1です。
現場での姿
最もよくあるのは、「テストが本物のディレクトリを見ている」ことです。os.chdirをしたり、/tmp/testのような固定のパスを使ったりして、2つのテストが互いに干渉し、順序によって結果が変わります。tmp_pathは、テストごとに別のパスなので、この問題がありません。2つ目は、時間をtime.sleep(2)で作るテストです。遅く、CIが忙しいときに失敗します。時間は、差し替えるものです。3つ目は、「直したのでテストはあとで」です。あとは来ません。失敗するテストを先に書くと、直す時間がむしろ減ります。再現が手元にあるからです。
次のラボですること
欠陥が2つ仕込まれたログローテーションのツール/opt/fixtures/pyops/buggy/rotate.pyを、作業ディレクトリにコピーして、純粋関数のテスト → tmp_pathで再現(失敗) → 直す → --dry-runの再現 → 直す → parametrize → capsys → monkeypatchで時間を固定 → --junitxmlのレポートの順に進みます。採点は、皆さんのテストを元の欠陥のあるコードに対しても実行して、そのテストが本当にバグを見つけるかを確認します。