【第2回】Python pytest 入門 — fixtureを使ったテストの前処理・後処理と共通化テクニック

プログラミング

テストコードを書いていたら、同じセットアップ処理を3ファイルにコピペしていて、さすがにこれは違うなと気づきました。

前回(第1回)は pytest のインストールから、テストファイル・関数の命名規則、assert の書き方、@pytest.mark.parametrize で複数パターンをまとめて回すあたりを紹介しました。今回はその続きで、pytest の中でも一番よく使う(そして一番つまずきやすい気がする)fixture の話です。

このセクションでわかること

  • fixture の基本的な概念と依存性注入の仕組み
  • yield を使った前処理・後処理の書き方
  • scope パラメータでの寿命制御
  • conftest.py による複数ファイル間での共通化方法
  • 組み込み fixture(tmp_path, monkeypatch など)の活用
  • パラメータ付き fixture による複数パターンのテスト

fixtureは「テストの前後にやることを名前で呼べる仕組み」

ざっくり言うと、テストの準備処理と片付け処理を関数として切り出して、テスト側は引数で受け取るだけにする仕組みです。unittest でいう setUp() / tearDown() に近いんですが、pytest はテスト関数の引数名と同名の fixture を探して差し込む(注入する)のが特徴です。

import pytest

@pytest.fixture
def sample_user():
    return {"id": 1, "name": "nyanchu", "role": "admin"}

def test_user_is_admin(sample_user):
    assert sample_user["role"] == "admin"

def test_user_name(sample_user):
    assert sample_user["name"].startswith("nyan")

ポイントは、テスト関数の引数名と fixture の関数名が一致していると pytest が勝手に紐づけてくれるところ。初見だと「引数に何も渡してないのになんで動くの?」となりますが、pytest が名前解決して差し込んでいます。いわゆる依存性注入っぽい動きですね。

あと地味に大事なのが、上の例だと sample_userテストごとに毎回作り直されるということ。test_user_is_admin で dict を書き換えても、test_user_name には影響しません。これがあるからテスト間の汚染を気にしなくて済みます。

yieldで後処理まで面倒を見る

fixture の中で return の代わりに yield を使うと、yield より下がテスト終了後に実行されます。DB接続とか一時ファイルとか、後片付けが必要なやつはこれ一択です。

@pytest.fixture
def db_session():
    conn = create_connection()
    session = conn.begin()
    yield session
    session.rollback()   # ここ、テストが失敗しても実行されることが多い
    conn.close()

「テストが失敗したら teardown もスキップされるのでは?」と最初は心配したんですが、基本的には後処理(yield より下)は実行されます。ただし、yield よりで例外が起きて「そもそも yield できなかった」場合は、後処理が走りません。なので重い準備処理はなるべく小さく分けたほうが安全っぽいです。

概念だけ切り出すと、やっていることは contextmanager とほぼ同じです。pytest なしで動く形にするとこんなイメージ。

from contextlib import contextmanager

@contextmanager
def db_connection(name):
    print(f"[setup] connect: {name}")
    yield {"name": name, "rows": []}
    print(f"[teardown] close: {name}")

def run_test(conn, label):
    conn["rows"].append(label)
    print(f"  test {label} -> rows={conn['rows']}")

with db_connection("session-db") as conn:
    for label in ("A", "B", "C"):
        run_test(conn, label)
print("done")

実際に実行した結果
▲実際にこのブログの裏側で実行してみた結果です

setup が1回、テストが3回、最後に teardown が1回。これがそのまま次に説明する scope の話につながります。

scopeで「どこまで使い回すか」を決める

fixture はデフォルトだと関数(テスト)ごとに作り直されますが、scope を指定すると寿命を伸ばせます。

  • function(デフォルト): テスト関数ごと
  • class: テストクラスごと
  • module: テストファイルごと
  • package: パッケージごと
  • session: pytest 実行全体で1回
@pytest.fixture(scope="session")
def app_config():
    return load_config("config/test.toml")

@pytest.fixture(scope="module")
def api_client(app_config):
    return Client(base_url=app_config["endpoint"])

スコープの広い fixture は、狭い fixture を要求できません。session スコープの fixture が function スコープのものに依存するとエラーになります(ScopeMismatch)。逆向き(狭いものが広いものを使う)はOK。この方向性、最初よく間違えました。

速度目的で安易に session にすると、テスト同士が状態を共有して「単体では通るのに全体実行だと落ちる」という一番デバッグしたくないバグを生みます。自分は読み取り専用の重いもの(設定、起動済みコンテナ、学習済みモデルのロードなど)だけ session、書き込みが絡むものは function、というゆるいルールにしています。

conftest.pyで複数ファイルに共通化する

冒頭のコピペ問題の答えがこれです。conftest.py というファイルに fixture を書いておくと、同じディレクトリ配下のテストからimport なしで使えます。

tests/
├── conftest.py          # 全テスト共通
├── unit/
│   └── test_parser.py
└── integration/
    ├── conftest.py      # integration配下だけ
    └── test_api.py
# tests/conftest.py
import pytest
from pathlib import Path
from datetime import datetime

@pytest.fixture(scope="session")
def project_root():
    return Path(__file__).parent.parent

@pytest.fixture
def frozen_now(monkeypatch):
    fixed = datetime(2026, 9, 1, 12, 0, 0)
    monkeypatch.setattr("app.utils.now", lambda: fixed)
    return fixed

階層が深いほうの conftest.py が優先されるので、同名 fixture で上書きもできます。便利ですが、やりすぎると「この fixture どこで定義されてるの?」となって新しく入った人が泣きます。困ったら pytest --fixtures で一覧が出るので、これは覚えておくと楽です。

autouseは慎重に

@pytest.fixture(autouse=True)
def reset_cache():
    cache.clear()
    yield
    cache.clear()

autouse=True にすると引数に書かなくても全テストで自動実行されます。ログ設定のリセットみたいな「全テストで絶対やりたいこと」には合いますが、暗黙の処理が増えるのでテストが読みにくくなる副作用もあります。個人的には conftest.py に1〜2個までかな、と。

組み込みfixtureを知らないと車輪を再発明する

pytest には最初から使える fixture がいろいろあって、自作する前にこれを調べたほうがいいやつです。

  • tmp_path: テストごとの一時ディレクトリ(pathlib.Path)。後片付け不要
  • monkeypatch: 属性・環境変数・カレントディレクトリを一時的に差し替え。テスト後に自動で戻る
  • capsys: 標準出力/標準エラーのキャプチャ
  • caplog: logging の出力を検証
  • request: 実行中のテスト情報にアクセス。パラメータ付き fixture で使う
def test_env(monkeypatch, tmp_path, capsys):
    monkeypatch.setenv("STAGE", "test")
    out = tmp_path / "result.json"
    run_export(out)
    print("exported")

    assert out.exists()
    assert "exported" in capsys.readouterr().out

自分は最初 os.environ["STAGE"] = "test" を直書きしていて、他のテストに環境変数が漏れて謎の失敗を踏みました。monkeypatch はテスト終了時に元に戻してくれるので、素直にこっちを使うべきでした。

パラメータ付きfixtureという合わせ技

前回やった parametrize は fixture 側にも書けます。

@pytest.fixture(params=["sqlite", "postgres"])
def db_backend(request):
    backend = setup_backend(request.param)
    yield backend
    backend.teardown()

def test_insert(db_backend):
    db_backend.insert({"id": 1})
    assert db_backend.count() == 1

この fixture を使うテストは自動で2回走ります。バックエンド違いで同じ振る舞いを確認したいときにハマる書き方ですが、正直ここは自分もまだ使いこなせていなくて、複雑になってくると parametrize + indirect=True のほうがいいのか毎回悩みます。もっといいやり方がある気がしています。

pytest 9系での仕様と安定性

現在 pytest は 9系が安定版です。fixture まわりの基本的な書き方は大きくは変わっていないので、既存の記事や書籍のサンプルはだいたいそのまま動くはずです。

ただ内部的には手が入っていて、9 以降は fixture の後処理(ファイナライザ)周りで内部実装が改善されています。ターミナルの進捗表示も微調整されていたり、細かい動作改善が続いているようです。そういえば最近は新しいバージョンが出たときにリリースノート を読むのがちょっとした趣味になっているんですが、変更の意図を追いかけるとツール開発の考え方が見えて面白いですね。

※この記事にはプロモーションが含まれます

そういえば、pytestの環境を整えてる時にふと自分の技術ブログも欲しくなって色々調べてたんですが、お名前.com レンタルサーバーが月額990円〜で独自ドメインも実質0円らしく

まとめ

fixture は「共通化のための便利機能」だと思って使い始めると、scope の設計で必ず一度は事故ります。自分の現時点の理解では、fixture の本質は共通化そのものよりライフサイクルの明示で、「いつ作って、いつ壊すか」を宣言できるところが効いているのかなと。だから scope を広げるときは速度ではなく「壊れても困らないか」で判断するようにしています。

ところで、みなさんの conftest.py って何行くらいあるんでしょうか。自分のは気づいたら200行を超えていて、そろそろ分割したほうがいい気配がしています。

📚 シリーズ「Python pytest 入門」(第2回 / 全4回)

← 前回の記事: 前回の記事はこちら

→ 次回の記事: 【第3回】Python pytest 入門 — parametrizeで複数パターンのテストをスッキリ書く方法

タイトルとURLをコピーしました