Pythonでpytestとfixtureでテストする

1. 概要

テストの数が増えると、データ準備部分(顧客、注文、データベース接続の作成など)が至るところで繰り返されるようになります。pytestはこの問題をfixtureで解決します。fixtureとは、テストにデータやリソースを提供し、テスト実行後に自動的にクリーンアップする関数です。

この記事は、最初のpytestテストからyieldを使ったfixture、スコープ、conftest.py、組み込みfixtureとパラメータ化まで説明します。読了後、Pythonのテストスイートを簡潔で読みやすく、重複コードの少ないものとして整理できるようになります。

2. pytestのインストール

pytestは仮想環境にインストールすることをお勧めします:

python -m venv .venv
source .venv/bin/activate
pip install pytest

この記事で使用するプロジェクト構造は以下のとおりです:

shop/
├── shop.py
└── tests/
    ├── conftest.py
    ├── test_order.py
    └── test_repository.py

pytestは自動的にtest_*.pyまたは*_test.pyという名前のファイルを見つけます。各ファイルで、test_で始まる関数を実行します。

testsディレクトリからshopをインポートするには、pyproject.tomlに以下の設定を追加します:

[tool.pytest.ini_options]
pythonpath = ["."]
testpaths = ["tests"]

3. 最初のテスト

shop.pyのビジネスロジックです。価格はdong(int型)で計算し、floatの誤差を避けます:

from dataclasses import dataclass, field


@dataclass
class Customer:
    name: str
    is_vip: bool = False


@dataclass
class Order:
    customer: Customer
    items: list[tuple[str, int, int]] = field(default_factory=list)

    def add_item(self, sku: str, quantity: int, price: int) -> None:
        if quantity <= 0:
            raise ValueError("quantity must be positive")
        self.items.append((sku, quantity, price))

    def total(self) -> int:
        subtotal = sum(qty * price for _, qty, price in self.items)
        return subtotal * 90 // 100 if self.customer.is_vip else subtotal

VIP顧客は10%割引を受けます。このロジックのテストはtests/test_order.pyにあります:

import pytest

from shop import Customer, Order


def test_total_for_regular_customer():
    order = Order(Customer("An"))
    order.add_item("BOOK-01", 2, 50_000)

    assert order.total() == 100_000


def test_add_item_rejects_zero_quantity():
    order = Order(Customer("An"))

    with pytest.raises(ValueError, match="quantity must be positive"):
        order.add_item("BOOK-01", 0, 50_000)

必要なのは2つだけです:

  • **assert**はPythonの純粋なもの。assertEqualや特別なアサーションライブラリは不要です。pytestはassert文を書き直して、詳細なエラーを報告します。
  • **pytest.raises**は、コードブロックが正しい型の例外を発生させるかチェックします。matchパラメータは例外メッセージと照合する正規表現です。

テストを実行します:

pytest -v
tests/test_order.py::test_total_for_regular_customer PASSED              [ 50%]
tests/test_order.py::test_add_item_rejects_zero_quantity PASSED          [100%]

============================== 2 passed in 0.01s ===============================

期待値を意図的に90_000に変更した場合、pytestは両側の値を出力します:

>       assert order.total() == 90_000
E       assert 100000 == 90000
E        +  where 100000 = total()

4. 基本的なfixture

4.1. fixtureの宣言と使用

上の2つのテストはどちらもCustomer("An")とOrderを作成しています。数十のテストがある場合、このコードは何度も繰り返されます。これをfixtureに移しましょう:

import pytest

from shop import Customer, Order


@pytest.fixture
def customer():
    return Customer("An")


@pytest.fixture
def order(customer):
    return Order(customer)


def test_total_for_regular_customer(order):
    order.add_item("BOOK-01", 2, 50_000)
    assert order.total() == 100_000


def test_empty_order_has_zero_total(order):
    assert order.total() == 0

仕組みは次のとおりです:

  • @pytest.fixtureは関数をfixtureとしてマークします。
  • テストはfixtureと同じ名前のパラメータを宣言します。pytestはfixtureを呼び出し、戻り値をテストに渡します。これは名前による依存性注入です。
  • fixture orderはfixture customerを受け取ります。fixtureは他のfixtureに依存でき、pytestが自動的に順序を解決します。

デフォルトでは、各テストは新しいインスタンスを受け取ります。したがって、最初のテストのadd_itemは2番目のテストのorderに影響を与えません。

4.2. ファクトリーfixture

テストが異なるパラメータを持つ複数の注文を必要とすることがあります。オブジェクトを返す代わりに、fixtureはオブジェクトを作成する関数を返すことができます:

@pytest.fixture
def make_order():
    def _make(name="An", is_vip=False, items=()):
        order = Order(Customer(name, is_vip))
        for sku, qty, price in items:
            order.add_item(sku, qty, price)
        return order
    return _make


def test_vip_gets_ten_percent_discount(make_order):
    regular = make_order(items=[("BOOK-01", 1, 200_000)])
    vip = make_order(name="Binh", is_vip=True, items=[("BOOK-01", 1, 200_000)])

    assert regular.total() == 200_000
    assert vip.total() == 180_000

このパターンは準備部分を簡潔に保ちながら、テストがデータを自由に決定できるようにします。

5. yieldを使ったセットアップとティアダウン

fixtureは、データベース接続、ファイル、またはモックサーバーなどのクリーンアップが必要なリソースを扱う場合に非常に便利です。SQLiteを使うリポジトリをshop.pyに追加します:

import sqlite3


class OrderRepository:
    def __init__(self, conn: sqlite3.Connection):
        self.conn = conn

    def save(self, customer_name: str, total: int) -> int:
        cur = self.conn.execute(
            "INSERT INTO orders (customer, total) VALUES (?, ?)",
            (customer_name, total),
        )
        return cur.lastrowid

    def count(self) -> int:
        return self.conn.execute("SELECT COUNT(*) FROM orders").fetchone()[0]

fixtureはreturnの代わりにyieldを使います:

import sqlite3

import pytest

from shop import OrderRepository


@pytest.fixture
def db_connection():
    conn = sqlite3.connect(":memory:")
    conn.execute("CREATE TABLE orders (id INTEGER PRIMARY KEY, customer TEXT, total INTEGER)")
    yield conn
    conn.close()


@pytest.fixture
def repository(db_connection):
    return OrderRepository(db_connection)


def test_save_order_returns_id(repository):
    order_id = repository.save("An", 100_000)

    assert order_id == 1
    assert repository.count() == 1

yieldの前の部分はセットアップです。yieldの後の値がテストに渡されます。yieldの後の部分はティアダウンであり、テストが失敗した場合でも常に実行されます。try/finallyは不要です。

--setup-showフラグは順序を明確に示します:

pytest tests/test_repository.py --setup-show
tests/test_repository.py
        SETUP    F db_connection
        SETUP    F repository (fixtures used: db_connection)
        tests/test_repository.py::test_save_order_returns_id (fixtures used: db_connection, repository).
        TEARDOWN F repository
        TEARDOWN F db_connection

Fはfunctionスコープを意味します。ティアダウンはセットアップと逆の順序で実行されます。

注意:yieldの前に例外が発生した場合、ティアダウン部分は実行されません。理由はfixtureのセットアップが完了していないためです。セットアップ部分は簡潔に保つか、セットアップが複数のエラーの可能性がある手順で構成されている場合は、自分でtry/finallyでラップしてください。

6. fixtureのスコープ

各テストのために高価なリソース(実際のデータベース接続、コンテナの起動など)を作成するとテストスイートが遅くなります。scopeパラメータはfixtureの生存期間を決定します:

Scope 再作成される時期
function(デフォルト) 各テスト
class 各クラステスト
module 各テストファイル
package 各パッケージ
session テスト実行全体で1回

例えば、ファイル全体で1つの接続を共有しますが、各テスト後にデータを削除します:

@pytest.fixture(scope="module")
def db_connection():
    conn = sqlite3.connect(":memory:")
    conn.execute("CREATE TABLE orders (id INTEGER PRIMARY KEY, customer TEXT, total INTEGER)")
    yield conn
    conn.close()


@pytest.fixture
def repository(db_connection):
    yield OrderRepository(db_connection)
    db_connection.execute("DELETE FROM orders")


def test_first_save(repository):
    repository.save("An", 100_000)
    assert repository.count() == 1


def test_second_save_starts_clean(repository):
    repository.save("Binh", 50_000)
    assert repository.count() == 1

db_connectionは1回だけ作成されます。repositoryはfunctionスコープなので、各テスト後にテーブルをクリアします。おかげで2番目のテストは空のテーブルを見ます。

重要なルールがあります:fixtureはスコープが同じかそれ以上広いfixtureだけを使用できます。sessionスコープのfixtureがfunctionスコープのfixtureを使用している場合、pytestはエラーを報告します:

ScopeMismatch: You tried to access the function scoped fixture repository
with a session scoped request object.

7. conftest.pyを通じてfixtureを共有する

fixture customerとmake_orderは多くのテストファイルに役立ちます。これらをtests/conftest.pyに置きます:

import pytest

from shop import Customer, Order


@pytest.fixture
def customer():
    return Customer("An")


@pytest.fixture
def order(customer):
    return Order(customer)

pytestは自動的にconftest.pyをロードします。そのディレクトリとサブディレクトリ内のすべてのテストは、インポートなしでこれらのfixtureを使用できます。複数のディレクトリレベルにconftest.pyを配置できます。テストに近いレベルのfixtureは、外側のレベルの同じ名前のfixtureをオーバーライドします。

**注意:**テストファイルでimport conftestしないでください。そうすると、fixtureが2回登録される可能性があり、予測不可能な動作につながります。

どのテストがどのfixtureを使用しているか、そのfixtureがどこで定義されているかを確認するには、次を実行してください:

pytest --fixtures-per-test tests/test_order.py

8. 組み込みfixture

pytestは多くの便利な組み込みfixtureを提供しています。最も一般的な3つはtmp_path、monkeypatch、capsysです。

shop.pyに2つの関数を追加すると仮定します:

import os
from pathlib import Path


def export_orders_csv(rows: list[tuple[str, int]], path: Path) -> None:
    lines = ["customer,total"] + [f"{name},{total}" for name, total in rows]
    path.write_text("\n".join(lines), encoding="utf-8")


class PaymentService:
    def __init__(self):
        self.api_key = os.environ["PAYMENT_API_KEY"]

    def charge(self, order: "Order") -> str:
        print(f"Charging {order.total()} VND")
        return "SUCCESS"

これらのテスト:

from shop import PaymentService, export_orders_csv


def test_export_orders_csv(tmp_path):
    output = tmp_path / "orders.csv"

    export_orders_csv([("An", 100_000), ("Binh", 50_000)], output)

    assert output.read_text(encoding="utf-8").splitlines() == [
        "customer,total",
        "An,100000",
        "Binh,50000",
    ]


def test_payment_service_charges_order(monkeypatch, capsys, order):
    monkeypatch.setenv("PAYMENT_API_KEY", "test-key")
    order.add_item("BOOK-01", 1, 75_000)

    result = PaymentService().charge(order)

    assert result == "SUCCESS"
    assert capsys.readouterr().out == "Charging 75000 VND\n"
  • **tmp_path**は各テスト専用の一時ディレクトリを指すpathlib.Pathです。テストはプロジェクトディレクトリを汚しません。
  • **monkeypatch**はテストのスコープ内で環境変数、属性、または関数を変更します。すべての変更はテスト終了時に自動的に元に戻されます。setenvの他に、monkeypatch.setattr(obj, "name", value)をよく使用します。
  • **capsys**はstdoutとstderrに出力されたコンテンツをキャプチャします。readouterr()はテストの開始以来または前回の呼び出し以来出力されたものを返します。

9. テストのパラメータ化

9.1. @pytest.mark.parametrize

同じロジックを複数のデータセットでテストする必要がある場合、テストをコピーすべきではありません。parametrizeを使用してください:

import pytest


@pytest.mark.parametrize(
    ("is_vip", "price", "expected"),
    [
        (False, 100_000, 100_000),
        (True, 100_000, 90_000),
        (True, 15, 13),
    ],
    ids=["regular", "vip", "vip-rounding-down"],
)
def test_total_by_customer_type(make_order, is_vip, price, expected):
    order = make_order(is_vip=is_vip, items=[("SKU", 1, price)])
    assert order.total() == expected
tests/test_order.py::test_total_by_customer_type[regular] PASSED
tests/test_order.py::test_total_by_customer_type[vip] PASSED
tests/test_order.py::test_total_by_customer_type[vip-rounding-down] PASSED

各パラメータセットは個別のテストであり、idsのおかげで読みやすいIDがあります。fixtureと(make_order)とパラメータを組み合わせることができることに注意してください。vip-rounding-downのケースは//の切り下げ動作を明確に記録しています。

9.2. パラメータを持つfixture

fixtureを使用するすべてのテストを複数のバリエーションで実行したい場合は、fixture自体をパラメータ化します:

@pytest.fixture(params=[False, True], ids=["regular", "vip"])
def any_customer(request):
    return Customer("An", is_vip=request.param)


def test_total_is_never_negative(any_customer):
    order = Order(any_customer)
    order.add_item("BOOK-01", 3, 10_000)
    assert order.total() >= 0

requestは特別なfixtureです。request.paramは現在の値を含みます。上記のテストは自動的に2回実行されます。

10. よくあるエラーと使用すべきでない場合

  • **fixtureの名前を間違える。**パラメータ名がfixtureと一致しない場合、fixture 'oder' not foundエラーが発生します。pytestは利用可能なfixtureをすぐ下にリストアップするので、よく読んで修正してください。
  • **fixtureを通常の関数のように呼び出す。**テスト内でcustomer()と書くと、Fixture "customer" called directlyエラーが発生します。パラメータとして宣言してください。
  • 可変データを持つ広いスコープ。moduleスコープを持つOrderにこのテストが項目を追加すると、別のテストが失敗します。結果は実行順序に依存し、非常にデバッグしにくくなります。広いスコープは高価で状態を変えないリソース、またはセクション6のようにfixture functionスコープ内でステートをクリアする場合のみ使用してください。
  • **autouse=Trueの乱用。**fixtureが自動的にすべてのテストに適用されるのは便利ですが、テストを読む人はそれがどこにあるかわかりません。本当にグローバルな場合(例えば、ネットワークを無効化するなど)にのみ使用してください。
  • fixtureのチェーンが深すぎる。orderがcustomerに依存し、customerがaddressに依存し、addressがcountryに依存する場合…、読者はテストを理解するために多くのファイルを飛び回る必要があります。単純なデータの場合、テスト内で直接作成するか、factory fixtureを使用する方が読みやすいことがよくあります。

**注意:**fixtureは環境を準備するためのツールであり、チェックロジックを含む場所ではありません。assertをテスト関数内に保つので、各テストが何をチェックしているかを自分で説明できます。

11. 結論

pytestの中核部分を説明しました。assertとpytest.raisesは結果をチェックするために使用されます。名前による依存性注入されたfixtureは、準備コードの繰り返しを排除するのに役立ちます。yieldは安全なセットアップ/ティアダウンを提供し、scopeはスピードと分離のバランスを取ります。conftest.pyは複数のファイル間でfixtureを共有します。tmp_path、monkeypatch、capsysのような組み込みfixtureはファイル、環境、出力を処理します。最後に、parametrizeとparamsを持つfixtureは、テストをコピーしなくても多くのケースをカバーするのに役立ちます。

テストスイートが大きくなるにつれ、fixture functionスコープで始めるべきです。スピードが実際に問題であることが測定できたときだけスコープを広げてください。

pythonpytesttestingfixtureunit-test