Pythonテスト #2 フィクスチャ: 準備と後片付けを注入してもらう

第1回 で pytest をインストールして最初のテストを回しました。ところがテストが数個増えただけで、新しい不便が生まれます。すべてのテストが冒頭で同じ準備コードを繰り返す問題です。今回はその繰り返しをなくす pytest の中核ツールである フィクスチャ (fixture) を扱います。

  • #1 pytest 入門
  • #2 フィクスチャ ← 今回
  • #3 parametrize とマーカー
  • #4 mock と monkeypatch
  • #5 外部世界のテスト
  • #6 テスト設計とカバレッジ
  • #7 CI 連携

繰り返される準備コード #

第1回の買い物かごの例にテストをいくつか追加すると、こんな形になります。

🚫 すべてのテストが同じ準備を繰り返す
def test_remove_item():
    cart = Cart()
    cart.add("りんご", 1000)
    cart.remove("りんご")
    assert cart.total() == 0

def test_clear():
    cart = Cart()
    cart.add("りんご", 1000)
    cart.clear()
    assert cart.total() == 0

def test_total():
    cart = Cart()
    cart.add("りんご", 1000)
    cart.add("梨", 2000)
    assert cart.total() == 3000

3 つのテストはどれも「りんごが入った買い物かご」を自分で作ってから始まります。Cart の生成方法が変わればすべてのテストを直すことになり、準備コードが長くなるほど、肝心の検証したい 1 行が埋もれてしまいます。

@pytest.fixture: 関数の引数として注入してもらう #

フィクスチャは「準備済みのオブジェクトを作る関数」です。@pytest.fixture を付けて定義し、テストは 同じ名前の引数 を宣言するだけで済みます。

✅ フィクスチャとして抽出
import pytest

@pytest.fixture
def cart():
    c = Cart()
    c.add("りんご", 1000)
    return c

def test_remove_item(cart):
    cart.remove("りんご")
    assert cart.total() == 0

def test_clear(cart):
    cart.clear()
    assert cart.total() == 0

テスト関数が cart という引数を宣言すると、pytest が同じ名前のフィクスチャ関数を探して実行し、戻り値を引数として渡してくれます。この構造は 依存性注入 という観点で見ると理解しやすいです。テストは「りんごが入った買い物かごが必要だ」と宣言するだけで、それを作る方法はフィクスチャが担当します。デフォルトではフィクスチャは テストごとに新しく実行される ので、あるテストが買い物かごを空にしても、次のテストはきれいな買い物かごを受け取ります。

yield フィクスチャ: 準備と後片付けを一ヶ所に #

ファイルや DB 接続のように 使ったあとに後片付けが必要なリソース は、return の代わりに yield を使います。

セットアップとティアダウン
import sqlite3
import pytest

@pytest.fixture
def db():
    conn = sqlite3.connect(":memory:")   # セットアップ
    conn.execute("CREATE TABLE users (name TEXT)")
    yield conn                           # ここでテストを実行
    conn.close()                         # ティアダウン

def test_insert(db):
    db.execute("INSERT INTO users VALUES ('カーティス')")
    rows = db.execute("SELECT count(*) FROM users").fetchone()
    assert rows[0] == 1

yield の前が準備、後ろが後片付けです。重要なのは テストが失敗しても後片付けのコードは必ず実行される という点です。try/finally をテストごとに書く代わりに、リソースのライフサイクルをフィクスチャ一ヶ所に集めておけます。

scope: 隔離とコストのトレードオフ #

フィクスチャがテストごとに新しく実行されるのは隔離には良いのですが、準備コストが大きいリソースだと負担になります。DB エンジンの起動に 2 秒かかり、テストが 100 個あったらどうでしょうか。scope オプションで生成の周期を延ばせます。

scope の指定
@pytest.fixture(scope="session")
def db_engine():
    engine = create_engine()   # 高コストな準備を一度だけ
    yield engine
    engine.dispose()
scope生成周期向いているリソース
functionテストごと (デフォルト)可変状態を持つオブジェクト全部
moduleファイルごとに 1 回ファイル単位で共有しても安全なリソース
session全体の実行で 1 回DB エンジン、コンテナのような高コストのリソース

scope を広げるほど速くなりますが、テスト間でオブジェクトが共有されます。あるテストが残した変更が次のテストから見えた瞬間に隔離が壊れるので、広い scope は読み取り専用に近いリソースだけに使うのが安全です。

conftest.py: フィクスチャの共有 #

フィクスチャをテストファイルごとにコピーする必要はありません。conftest.py という名前のファイルに定義すると、import なしで 同じディレクトリと配下のディレクトリのすべてのテストから使えます。

ディレクトリ階層
tests/
├── conftest.py        # cart フィクスチャ: すべてのテストで使用可能
├── test_cart.py
└── api/
    ├── conftest.py    # client フィクスチャ: api/ 以下でのみ使用可能
    └── test_orders.py

pytest はテストを実行するとき、そのファイルの位置から上位ディレクトリの方向へ conftest.py をすべて読み込んでフィクスチャを集めます。そのため、共用のフィクスチャは上の階層に、特定の領域専用のフィクスチャはそのディレクトリの conftest.py に置くという階層構造が自然にでき上がります。

フィクスチャがフィクスチャを使う合成 #

フィクスチャも他のフィクスチャを引数として受け取れます。

フィクスチャの合成
@pytest.fixture
def user():
    return User(name="カーティス")

@pytest.fixture
def cart(user):
    return Cart(owner=user)

def test_owner(cart):
    assert cart.owner.name == "カーティス"

cartuser に依存すると宣言すれば、pytest が依存関係の順序どおりに実行してくれます。小さなフィクスチャを組み立てて複雑な状況を作るこのパターンのほうが、あとで見る万能フィクスチャよりずっとメンテナンスしやすいです。

組み込みフィクスチャの味見: tmp_path と capsys #

pytest には定義なしでそのまま使える組み込みフィクスチャがいくつもあります。最もよく使う 2 つだけ紹介します。

tmp_path と capsys
def test_save_report(tmp_path):
    file = tmp_path / "report.txt"     # テスト専用の一時ディレクトリ
    save_report(file, "売上 1000")
    assert file.read_text() == "売上 1000"

def test_greet(capsys):
    greet("カーティス")
    captured = capsys.readouterr()     # print 出力をキャプチャ
    assert "カーティス" in captured.out

tmp_path はテストごとに固有の一時ディレクトリを pathlib.Path として渡してくれて、後片付けも自動でやってくれます。ファイルを扱うテストで実際のディレクトリを散らかす心配がなくなります。capsys は標準出力をキャプチャして、print の結果を検証できるようにします。全体のリストは pytest --fixtures で確認できます。

アンチパターン 2 つ #

まず警戒すべきは 万能フィクスチャ です。ユーザー、買い物かご、DB、設定まで全部準備する巨大なフィクスチャ 1 つを、すべてのテストが受け取る構造です。テストが実際に何へ依存しているのかが見えず、フィクスチャを直すたびに無関係のテストが壊れます。上で見た合成パターンで小さく分割し、テストには必要なものだけを宣言させてください。

scope の乱用 もよくあります。遅いからという理由で可変オブジェクトを session scope に上げると、あるテストが残した状態のせいで、実行順序によって結果が変わるテストが生まれます。単独では通るのに全体実行では失敗するテストを見かけたら、たいていこのケースです。広い scope は高コストかつ読み取り専用のリソースにだけ許可する原則を守るのが良いです。

まとめ #

今回扱った内容です。

  • @pytest.fixture で準備コードを抽出し、テストは引数の名前で注入してもらいます
  • yield フィクスチャはセットアップとティアダウンを一ヶ所に集め、テストが失敗しても後片付けを保証します
  • scope は隔離とコストのトレードオフで、広い scope は読み取り専用のリソースだけに使うのが安全です
  • conftest.py に置けば import なしでディレクトリ階層全体にフィクスチャを共有できます
  • フィクスチャがフィクスチャを受け取る合成で、小さな部品を組み立てます
  • tmp_pathcapsys のような組み込みフィクスチャは定義なしでそのまま使います

次回 (#3 parametrize とマーカー) では、同じテストロジックを複数の入力で繰り返し実行する @pytest.mark.parametrize と、テストを分類して選択実行するマーカーを扱います。

X