Python | テスト・設計・品質:fixture

Python Python
スポンサーリンク

fixture って何?まずはイメージから

pytest の fixture(フィクスチャ)は、
「テストのたびに必要になる“準備”を、共通化して再利用する仕組み」です。

毎回のテストで同じようなことをしていませんか?

テスト用のデータを用意する
一時ファイルや一時ディレクトリを作る
DB 接続や Session を作る
テスト後に片付ける(クローズ・削除など)

これを全部テスト関数の中に書いていると、
コードが重複するし、片付け忘れもしやすい。

fixture は、こういう「前準備」と「後片付け」を
一箇所にまとめて、テストからは「引数として受け取るだけ」にしてくれる仕組みです。


いちばん小さい fixture の例から始める

テスト対象の関数を用意する

まずは、シンプルな関数を用意します。
calc.py とします。

# calc.py

def add(a: int, b: int) -> int:
    return a + b
Python

ここに対して、「よく使う入力セット」を fixture で用意してみます。

fixture を定義する基本形

pytest の fixture は、@pytest.fixture を付けた関数です。

# test_calc.py

import pytest
from calc import add

@pytest.fixture
def sample_numbers():
    return (1, 2)

def test_add_with_sample_numbers(sample_numbers):
    a, b = sample_numbers
    assert add(a, b) == 3
Python

ここで起きていることを分解すると、

sample_numbers という名前の fixture を定義している
テスト関数 test_add_with_sample_numbers の引数に sample_numbers と書く
pytest が「この引数名と同じ fixture を探して、戻り値を渡してくれる」

という流れです。

テスト側から見ると、

「引数に書くだけで、準備済みのデータがもらえる」

という感覚で使えます。


fixture の一番おいしいところ:「共通の準備」を一箇所にできる

fixture がないときの重複コード

例えば、ユーザー辞書を使う関数をテストしたいとします。

# user.py

def get_full_name(user: dict) -> str:
    return f"{user['last_name']} {user['first_name']}"
Python

fixture を使わないと、テストはこうなりがちです。

# test_user.py

from user import get_full_name

def test_get_full_name_normal():
    user = {"first_name": "Taro", "last_name": "Yamada"}
    assert get_full_name(user) == "Yamada Taro"

def test_get_full_name_other():
    user = {"first_name": "Hanako", "last_name": "Suzuki"}
    assert get_full_name(user) == "Suzuki Hanako"
Python

ここではまだマシですが、
もっと複雑な「ユーザーオブジェクト」を毎回作るようになると、
テスト関数ごとに同じような準備コードが並び始めます。

fixture にまとめるとスッキリする

同じ準備を fixture にすると、こう書けます。

# test_user.py

import pytest
from user import get_full_name

@pytest.fixture
def user_taro():
    return {"first_name": "Taro", "last_name": "Yamada"}

@pytest.fixture
def user_hanako():
    return {"first_name": "Hanako", "last_name": "Suzuki"}

def test_get_full_name_normal(user_taro):
    assert get_full_name(user_taro) == "Yamada Taro"

def test_get_full_name_other(user_hanako):
    assert get_full_name(user_hanako) == "Suzuki Hanako"
Python

テスト関数の中から「準備コード」が消えて、
「何をテストしているか」だけが見えるようになります。

fixture の役割は、

テストの「前準備」を一箇所に集める
テスト本体は「振る舞いの確認」に集中させる

というところにあります。


前準備だけじゃない:「後片付け」も fixture に任せられる

一時ファイルを使うテストを考える

例えば、「ファイルに書き込んでから読む」関数をテストしたいとします。

# fileutil.py

def write_and_read(path: str, text: str) -> str:
    with open(path, "w", encoding="utf-8") as f:
        f.write(text)
    with open(path, "r", encoding="utf-8") as f:
        return f.read()
Python

テストでは、一時ファイルを作って、終わったら消したくなります。

fixture を使わないと、テスト関数の中でこうなります。

# test_fileutil.py

import os
from fileutil import write_and_read

def test_write_and_read_tmpfile():
    path = "tmp_test.txt"
    try:
        result = write_and_read(path, "hello")
        assert result == "hello"
    finally:
        if os.path.exists(path):
            os.remove(path)
Python

テストごとに try/finally と削除処理を書くのは面倒です。

fixture で「前準備+後片付け」をまとめる

pytest の fixture は、yield を使うことで
「前準備」と「後片付け」を 1 つの関数にまとめられます。

# test_fileutil.py

import os
import pytest
from fileutil import write_and_read

@pytest.fixture
def tmp_file_path():
    path = "tmp_test.txt"
    yield path
    if os.path.exists(path):
        os.remove(path)

def test_write_and_read_tmpfile(tmp_file_path):
    result = write_and_read(tmp_file_path, "hello")
    assert result == "hello"
Python

ここでの流れはこうです。

fixture 関数が呼ばれる
yield path まで実行され、その値がテスト関数に渡される
テスト関数が実行される
テストが終わったあと、yield の後ろのコードが実行される(後片付け)

つまり、yield を境に、

前半:セットアップ(準備)
後半:ティアダウン(片付け)

を 1 つの fixture にまとめられるわけです。

これが、DB 接続のクローズ、トランザクションのロールバック、
一時ディレクトリの削除などでめちゃくちゃ役に立ちます。


fixture のスコープ(どの単位で使い回すか)

scope=”function”(デフォルト)

何も指定しないと、fixture は「テスト関数ごと」に実行されます。

@pytest.fixture
def sample():
    print("setup")
    return 123
Python

この fixture を 3 つのテストで使うと、
3 回「setup」が実行されます。

テストごとに完全に独立した状態が欲しいときは、
このデフォルトの挙動が安全です。

scope=”module” や “session”

「毎回作り直すのは重いから、モジュール単位・テスト全体で共有したい」
というケースもあります。
例えば、テスト用の DB を 1 回だけ立ち上げたいときなどです。

@pytest.fixture(scope="module")
def db_connection():
    print("connect DB")
    conn = create_connection()
    yield conn
    print("close DB")
    conn.close()
Python

scope="module" にすると、

そのファイル(モジュール)のテストが始まるときに 1 回だけ作られる
そのモジュール内のテストから共有される
モジュールのテストが全部終わったら片付けが走る

という動きになります。

scope="session" にすると、「pytest 実行全体で 1 回だけ」になります。

スコープをどうするかは、

毎回クリーンな状態が必要か
重い初期化を何度もやりたくないか

のバランスで決めます。


fixture を使うときに意識してほしい設計の感覚

「テストが何を前提にしているか」を fixture 名で表す

fixture 名は、そのテストが「何を前提にしているか」を表す名前にすると読みやすくなります。

例えば、

user_taro
logged_in_client
db_session
tmp_dir

のように、「このテストはこういう状態から始まる」というのが
引数名だけで伝わると、テストコードがかなり自己説明的になります。

逆に、data1objenv みたいな名前だと、
「これ何だっけ?」と毎回定義を見に行くことになりがちです。

「準備が複雑になってきたら fixture に逃がす」

最初は、テスト関数の中に直接準備コードを書いていても構いません。
ただ、

同じ準備が複数のテストで繰り返されている
テスト関数の上半分が準備で埋まっている

と感じたら、それは fixture に切り出すサインです。

fixture に切り出すと、

準備のロジックを 1 箇所で管理できる
テスト本体は「期待する振る舞い」だけに集中できる
後片付けもまとめて書ける

というメリットが一気に出てきます。


まとめ(fixture は「テストの土台を整える職人」)

pytest の fixture を初心者目線で整理すると、こうなります。

@pytest.fixture を付けた関数で「前準備」を定義し、テスト関数の引数にその名前を書くと、pytest が自動でその戻り値を渡してくれる。
yield を使うと、「前準備」と「後片付け」を 1 つの fixture にまとめられる。テストが終わったあとに yield の後ろが実行される。
scope を変えることで、「テストごと」「ファイルごと」「テスト全体」で使い回すかを調整できる。
同じ準備コードがテストに散らばり始めたら、それを fixture に集約することで、テストが読みやすく・壊れにくくなる。

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