【第2回】Python pytest 入門 — fixtureとスコープを使ってテストコードをDRYに書く

プログラミング

前回はpytestの基本的な書き方と、FastAPIアプリに対してシンプルなテストを動かすところまでやりました。テストが通ったときの達成感、あれは本当にいい。

今回はそこから一歩進んで、fixture(フィクスチャ)を使う話をします。テストを書き続けると「同じ準備処理をあちこちに書いてるな…」という瞬間が来るんですが、fixtureはそれを解消するための仕組みです。

正直、最初は「なんでわざわざこんな書き方するんだろ」と思ってたんですが、使い始めると手放せなくなります。

この記事でわかること

  • fixtureの基本的な書き方と役割
  • FastAPIのTestClientをfixtureで管理する方法
  • yieldを使ったセットアップとティアダウンの書き方
  • scopeパラメータでfixtureの実行タイミングを制御する
  • 複数のfixtureをネストして組み合わせる方法
  • autouseで自動的にfixtureを適用する

fixtureとは何か、まず整理する

fixtureは「テストの前後に実行したい処理をまとめておく関数」です。たとえば:

  • テスト用のDBをセットアップしてテスト後に消す
  • APIクライアントを用意して使い回す
  • テスト用のサンプルデータを作っておく

こういった処理を各テスト関数にベタ書きしていくと、ちょっとした変更のたびに全ファイルを直す羽目になります。fixtureを使うとその処理を1か所にまとめられるので、DRY(Don’t Repeat Yourself)な状態が保てます。

書き方はシンプルで、@pytest.fixtureデコレータをつけるだけです。

import pytest

@pytest.fixture
def sample_user():
    return {"name": "ニャンチュー", "email": "nyanchu@example.com"}

def test_user_name(sample_user):
    assert sample_user["name"] == "ニャンチュー"

テスト関数の引数名とfixture名を揃えると、pytestが自動で渡してくれます。このあたりの「暗黙の解決」は最初ちょっと魔法っぽく見えますが、慣れると便利です。

FastAPIのTestClientをfixtureにする

前回はテスト関数の中に直接 TestClient(app) を書いていましたが、これをfixtureに切り出すのが基本的なパターンです。

まずは前回の構成をおさらい。

myapp/
├── main.py
└── tests/
    ├── conftest.py   ← 今回ここを使う
    └── test_items.py

conftest.py というファイルはpytestが自動で読み込む特殊なファイルで、ここに書いたfixtureは同じディレクトリ以下のテスト全体で使えます。TestClientのfixtureはここに置くのが定番です。

# tests/conftest.py
import pytest
from fastapi.testclient import TestClient
from myapp.main import app

@pytest.fixture
def client():
    yield TestClient(app)

return ではなく yield を使っているのがポイントです。yieldの前が「セットアップ」、yieldの後が「ティアダウン(後処理)」になります。今回は後処理がないのでyieldより後には何も書いていませんが、DBの後片付けをしたいときはyield以降に書けばOKです。

# tests/test_items.py
def test_get_items(client):
    response = client.get("/items")
    assert response.status_code == 200

テスト側はclientを引数に書くだけ。すっきりしました。

yieldでセットアップ・ティアダウンを一緒に書く

もう少し実践的な例として、テスト用DBを用意するfixtureを見てみます。

余談ですが、最近Dockerを使ったテスト用DB構築も試してみてて、これはこれで別途記事にしたいところ。今回はシンプルにSQLiteのインメモリDBで話を進めます。

# tests/conftest.py
import pytest
from fastapi.testclient import TestClient
from sqlmodel import SQLModel, create_engine, Session
from myapp.main import app
from myapp.database import get_session

TEST_DB_URL = "sqlite:///:memory:"  # インメモリSQLite

@pytest.fixture
def session():
    engine = create_engine(TEST_DB_URL, connect_args={"check_same_thread": False})
    SQLModel.metadata.create_all(engine)

    with Session(engine) as session:
        yield session  # ← ここまでがセットアップ

    SQLModel.metadata.drop_all(engine)  # ← テスト後にテーブルを消す


@pytest.fixture
def client(session):  # ← sessionを受け取る
    def override_get_session():
        yield session

    app.dependency_overrides[get_session] = override_get_session
    yield TestClient(app)
    app.dependency_overrides.clear()  # ← テスト後にオーバーライドを戻す

dependency_overrides はFastAPIの機能で、DIで注入している依存関係をテスト用のものに差し替えられます。本番用のDBセッションをテスト用に置き換えるときに使います。個人的にここが最初一番わかりにくかったポイントで、正直今も「これで合ってるかな」と毎回ドキュメント確認しながら書いてます。

scopeでfixtureの実行タイミングを制御する

デフォルトのfixtureはテスト関数ごとに毎回実行されます。つまり10個のテストがあれば、10回セットアップが走ります。

これが問題になるのは「DBの初期化やアプリ起動など、重い処理を毎回やりたくない」という場面です。そこで使うのが scope パラメータです。

scopeには4種類あります。

  • function(デフォルト): テスト関数ごとに実行
  • class: テストクラスごとに1回実行
  • module: テストファイル(モジュール)ごとに1回実行
  • session: pytestコマンド全体で1回だけ実行
# tests/conftest.py
import pytest
from fastapi.testclient import TestClient
from myapp.main import app

@pytest.fixture(scope="module")  # ← ファイルごとに1回だけ
def client():
    yield TestClient(app)

TestClientのように毎回作り直す必要がないものは scope="module" にしておくと、テストが速くなります。一方、DBセッションのように「テストごとに独立した状態を保ちたい」ものはデフォルトの function スコープのままにしておくのが安全です。

scopeの組み合わせで注意があって、広いスコープのfixtureが狭いスコープのfixtureに依存することはできません。たとえば scope="session" のfixtureが scope="function" のfixtureを引数にとろうとするとエラーになります。最初これで引っかかりました。

fixtureをネストして使う

fixtureは他のfixtureを引数として受け取れます。これが便利で、処理を役割ごとに分割して組み合わせられます。

# tests/conftest.py

@pytest.fixture(scope="session")
def engine():
    _engine = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False})
    SQLModel.metadata.create_all(_engine)
    yield _engine
    SQLModel.metadata.drop_all(_engine)


@pytest.fixture
def session(engine):  # ← engineを受け取る
    with Session(engine) as s:
        yield s


@pytest.fixture
def client(session):  # ← sessionを受け取る
    def override():
        yield session

    app.dependency_overrides[get_session] = override
    yield TestClient(app)
    app.dependency_overrides.clear()

engineはセッション全体で1回だけ起動、DBセッションはテストごとにリセット、という使い分けができます。もっといい構成があるかもしれませんが、自分はこのパターンをよく使っています。

autouseでfixtureを自動適用する

fixtureには autouse=True というオプションもあって、これを使うとテスト関数が明示的に引数に書かなくても自動で実行されます。

@pytest.fixture(autouse=True)
def reset_db(session):
    yield
    session.rollback()  # テストごとにロールバック

全テストでDBをロールバックしたいとか、ログを毎回クリアしたいとか、「とにかく全テストで走らせたい処理」に使います。ただ、autouseを乱用すると「なんでこの処理が動いてるんだっけ」と後から混乱しやすいので、使いどころは絞った方がいいと思ってます。

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

ちなみに、お名前.com レンタルサーバー(WordPressに特化した高速レンタルサーバー。月額990円〜、独自ドメイン実質0円)も気になっています。お名前.com レンタルサーバー

まとめ

今回のポイントを駆け足でまとめると、fixtureはテストの準備・後片付けをDRYに書くための仕組みで、yieldを使えばセットアップとティアダウンを1つの関数に収められます。scopeでどの粒度で再利用するかを制御できて、conftest.pyに書けばファイルをまたいで共有できます。

autouseを使えば明示的な引数なしに自動適用できますし、複数のfixtureをネストして組み合わせることで、テストコードの構造もすっきり整理できます。

📚 シリーズ「Python pytest 入門 — FastAPIアプリを題材にテスト設計をゼロから学ぶ」(第2回 / 全4回)

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

→ 次回の記事: 【第3回】Python pytest 入門 — モック・パッチでDBや外部APIへの依存を切り離す

参考になったらクリックしてもらえると嬉しいです!

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