pytestでPythonテストを効率化する—フィクスチャとモックの使い方

pytestでPythonテストを効率化する—フィクスチャとモックの使い方 | mohablog

「テストは大事」と頭ではわかっていても、実際にプロジェクトでテストコードを書き始めるのは腰が重い。以前、標準ライブラリのunittestで書こうとして、クラスの継承やらsetUpやらの記法に面倒さを感じて挫折した経験がある。

そんなとき出会ったのがpytestだった。関数を書いてassertするだけ。このシンプルさのおかげでテストを書く心理的なハードルがかなり下がった。この記事では、pytestの基本的な使い方からフィクスチャやモックまで、プロジェクトで必要になる知識を一通り整理していく。

使用環境: Python 3.12, pytest 8.3

目次

pytestとは?unittestとの違い

pytestの特徴

pytestはPythonのテストフレームワークで、標準ライブラリのunittestに比べてシンプルな記法が特徴だ。テスト関数にtest_プレフィックスをつけてassertを書くだけでテストになる。クラスを継承する必要もない。

公式ドキュメントによると、pytestには以下のような強みがある。

  • シンプルなassert文でテストが書ける(特別なassertメソッド不要)
  • test_で始まるファイル・関数を自動検出してくれる
  • フィクスチャ機構で前処理・後処理をすっきり管理できる
  • 1,000以上のプラグインによる拡張性

unittest vs pytest 比較表

観点unittestpytest
テストの記法クラスベース(TestCase継承)関数ベース(classも可)
アサーションassertEqual, assertTrueなど専用メソッド標準のassert文
テスト検出手動設定が必要な場合あり自動検出(命名規則ベース)
前処理・後処理setUp / tearDown メソッドフィクスチャ(fixture)
プラグイン限定的1,000以上のプラグイン
パラメータ化subTestで部分対応@pytest.mark.parametrize

unittestはPythonに同梱されているため追加インストール不要という利点はあるが、コードの記述量はpytestの方が明らかに少ない。現場でもpytestを採用しているケースが多い印象だ。

環境構築と最初のテストを書く

インストールと設定

まずはpytestをインストールする。uvを使っている場合は開発依存として追加するのがおすすめ。パッケージ管理ツールの選び方についてはPythonのパッケージ管理はuvで統一すべき?pip・poetryとの違いで詳しく触れている。

# pipの場合
pip install pytest

# uvの場合
uv add --dev pytest
Successfully installed pytest-8.3.5

pyproject.tomlにpytestの設定を追加しておくと便利だ。テストディレクトリの指定や出力オプションをまとめて管理できる。

[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = ["src"]
addopts = "-v --tb=short"

最初のテストを実行する

簡単な関数とそのテストを書いてみる。

# src/calculator.py
def add(a: int, b: int) -> int:
    return a + b

def divide(a: int, b: int) -> float:
    if b == 0:
        raise ValueError("0で割ることはできません")
    return a / b
# tests/test_calculator.py
from calculator import add, divide
import pytest

def test_add_positive_numbers():
    assert add(2, 3) == 5

def test_add_negative_numbers():
    assert add(-1, -2) == -3

def test_divide_normal():
    assert divide(10, 2) == 5.0

def test_divide_by_zero():
    with pytest.raises(ValueError, match="0で割ることはできません"):
        divide(10, 0)
$ pytest
========================= test session starts =========================
collected 4 items

tests/test_calculator.py::test_add_positive_numbers PASSED
tests/test_calculator.py::test_add_negative_numbers PASSED
tests/test_calculator.py::test_divide_normal PASSED
tests/test_calculator.py::test_divide_by_zero PASSED

========================= 4 passed in 0.03s ==========================

pytest.raisesを使えば例外のテストも簡潔に書ける。match引数に正規表現を渡すことで、エラーメッセージの内容まで検証可能だ。

フィクスチャで前処理・後処理を管理する

基本的なフィクスチャの使い方

テストにはデータベース接続やテストデータの準備など、前処理が必要なケースが多い。pytestでは@pytest.fixtureデコレータを使って前処理をフィクスチャとして定義する。

import pytest

@pytest.fixture
def sample_users():
    """テスト用のユーザーデータを返すフィクスチャ"""
    return [
        {"name": "田中", "age": 28, "role": "engineer"},
        {"name": "佐藤", "age": 35, "role": "manager"},
        {"name": "鈴木", "age": 22, "role": "intern"},
    ]

def test_user_count(sample_users):
    assert len(sample_users) == 3

def test_engineer_exists(sample_users):
    engineers = [u for u in sample_users if u["role"] == "engineer"]
    assert len(engineers) == 1
    assert engineers[0]["name"] == "田中"
$ pytest tests/test_users.py -v
tests/test_users.py::test_user_count PASSED
tests/test_users.py::test_engineer_exists PASSED

テスト関数の引数にフィクスチャ名を書くだけで、pytestが自動的にインジェクションしてくれる。この仕組みは最初ちょっと不思議に感じるが、慣れるとテストの見通しが良くなる。

yieldで後処理を実装する

フィクスチャで一時ファイルやDB接続を扱う場合、テスト後のクリーンアップも必要になる。yieldを使えば、前処理と後処理を1つのフィクスチャにまとめられる。

import pytest
import tempfile
import os

@pytest.fixture
def temp_config_file():
    """一時的な設定ファイルを作成し、テスト後に削除する"""
    fd, path = tempfile.mkstemp(suffix=".json")
    with os.fdopen(fd, "w") as f:
        f.write('{"debug": true, "port": 8080}')

    yield path  # ここでテストが実行される

    # テスト後のクリーンアップ
    os.unlink(path)

def test_config_file_exists(temp_config_file):
    assert os.path.exists(temp_config_file)

def test_config_file_content(temp_config_file):
    with open(temp_config_file) as f:
        content = f.read()
    assert '"debug": true' in content
$ pytest tests/test_config.py -v
tests/test_config.py::test_config_file_exists PASSED
tests/test_config.py::test_config_file_content PASSED

フィクスチャのスコープとconftest.py

デフォルトでは各テスト関数ごとにフィクスチャが実行されるが、scopeパラメータで実行頻度を変更できる。

スコープ実行タイミングユースケース
function(デフォルト)各テスト関数ごと軽量なテストデータ
classテストクラスごとクラス内で共有するリソース
moduleモジュール(ファイル)ごとDB接続、APIクライアント
sessionテストセッション全体で1回重い初期化(Dockerコンテナ起動など)
# conftest.py
import pytest

@pytest.fixture(scope="session")
def db_connection():
    """セッション全体で1つのDB接続を使い回す"""
    conn = create_test_db_connection()
    yield conn
    conn.close()

@pytest.fixture(scope="function")
def clean_db(db_connection):
    """各テスト前にテーブルをクリーンアップ"""
    db_connection.execute("DELETE FROM users")
    yield db_connection

conftest.pyに定義したフィクスチャは、同じディレクトリ以下のすべてのテストファイルからimportなしで使える。テストプロジェクト全体で共有したいフィクスチャはここに集約するのが一般的だ。

パラメータ化テストで重複を減らす

アンチパターン:コピペでテストを量産する

まず、やりがちなNGパターンから見てみる。

# NG: 同じ構造のテストをコピペで量産している
def test_validate_email_valid():
    assert validate_email("user@example.com") is True

def test_validate_email_valid_subdomain():
    assert validate_email("user@mail.example.com") is True

def test_validate_email_invalid_no_at():
    assert validate_email("userexample.com") is False

def test_validate_email_invalid_no_domain():
    assert validate_email("user@") is False

def test_validate_email_invalid_empty():
    assert validate_email("") is False

入力と期待値が変わるだけで、テストのロジックは全く同じだ。こういうケースでは@pytest.mark.parametrizeを使う。

@pytest.mark.parametrizeで書き直す

# OK: parametrizeで1つのテスト関数にまとめる
import pytest

@pytest.mark.parametrize("email, expected", [
    ("user@example.com", True),
    ("user@mail.example.com", True),
    ("userexample.com", False),
    ("user@", False),
    ("", False),
])
def test_validate_email(email, expected):
    assert validate_email(email) == expected
$ pytest tests/test_email.py -v
tests/test_email.py::test_validate_email[user@example.com-True] PASSED
tests/test_email.py::test_validate_email[user@mail.example.com-True] PASSED
tests/test_email.py::test_validate_email[userexample.com-False] PASSED
tests/test_email.py::test_validate_email[user@-False] PASSED
tests/test_email.py::test_validate_email[-False] PASSED

テストケースを追加したいときもタプルを1行足すだけで済む。テスト名にもパラメータが自動で反映されるため、どのケースが失敗したか一目でわかるのが地味にありがたい。

monkeypatchとpytest-mockで外部依存をモックする

monkeypatchで環境変数やモジュールを差し替える

外部APIやファイルシステムに依存するコードをテストするとき、そのまま実行すると不安定なテストになりがちだ。pytestのmonkeypatchフィクスチャを使えば、テスト中だけ特定の値を差し替えられる。

# src/config.py
import os

def get_api_base_url() -> str:
    url = os.environ.get("API_BASE_URL")
    if not url:
        raise RuntimeError("API_BASE_URL is not set")
    return url
# tests/test_config.py
from config import get_api_base_url
import pytest

def test_get_api_base_url(monkeypatch):
    monkeypatch.setenv("API_BASE_URL", "https://api.example.com")
    assert get_api_base_url() == "https://api.example.com"

def test_get_api_base_url_not_set(monkeypatch):
    monkeypatch.delenv("API_BASE_URL", raising=False)
    with pytest.raises(RuntimeError):
        get_api_base_url()
$ pytest tests/test_config.py -v
tests/test_config.py::test_get_api_base_url PASSED
tests/test_config.py::test_get_api_base_url_not_set PASSED

monkeypatchによる変更はテスト関数のスコープ内に限定されるため、他のテストに影響しない。環境変数だけでなく、monkeypatch.setattrでモジュール内の関数やクラスの属性も差し替えられる。

pytest-mockでより柔軟にモックする

外部APIを呼ぶ関数をテストする場合は、pytest-mockプラグインが便利だ。unittest.mockのラッパーで、pytestのフィクスチャとして自然に使える。

pip install pytest-mock
# src/user_service.py
import httpx

def fetch_user(user_id: int) -> dict:
    response = httpx.get(f"https://api.example.com/users/{user_id}")
    response.raise_for_status()
    return response.json()
# tests/test_user_service.py
from user_service import fetch_user
import httpx
import pytest

def test_fetch_user_success(mocker):
    mock_response = mocker.Mock()
    mock_response.json.return_value = {"id": 1, "name": "Tanaka"}
    mock_response.raise_for_status = mocker.Mock()

    mocker.patch("user_service.httpx.get", return_value=mock_response)

    result = fetch_user(1)
    assert result == {"id": 1, "name": "Tanaka"}

def test_fetch_user_not_found(mocker):
    mock_response = mocker.Mock()
    mock_response.raise_for_status.side_effect = httpx.HTTPStatusError(
        "404", request=mocker.Mock(), response=mocker.Mock(status_code=404)
    )
    mocker.patch("user_service.httpx.get", return_value=mock_response)

    with pytest.raises(httpx.HTTPStatusError):
        fetch_user(999)
$ pytest tests/test_user_service.py -v
tests/test_user_service.py::test_fetch_user_success PASSED
tests/test_user_service.py::test_fetch_user_not_found PASSED

mocker.patchのポイントは、モックする対象はインポート先のパスで指定すること。httpx.getではなくuser_service.httpx.getと書く必要がある。調べてみたら、これはPythonのimportの仕組みに起因していて、モジュールが参照を持つ時点のオブジェクトを差し替える必要があるためだった。ここを間違えるとモックが効かずにハマるので注意してほしい。

テスト設計でよくあるアンチパターン

テスト間に暗黙の依存がある

# NG: テストの実行順序に依存している
class TestUserFlow:
    created_user_id = None

    def test_create_user(self, client):
        res = client.post("/users", json={"name": "test"})
        TestUserFlow.created_user_id = res.json()["id"]
        assert res.status_code == 201

    def test_get_user(self, client):
        # test_create_userが先に実行されることを前提にしている
        res = client.get(f"/users/{TestUserFlow.created_user_id}")
        assert res.status_code == 200

テスト間でクラス変数やグローバル変数を通じてデータを受け渡すと、実行順序に依存した壊れやすいテストになる。pytest-randomlyのようなプラグインでテスト順序がシャッフルされた途端に失敗するパターンだ。各テストは独立して動くように設計するのが基本になる。

# OK: フィクスチャで各テストに必要なデータを準備する
import pytest

@pytest.fixture
def created_user(client):
    res = client.post("/users", json={"name": "test"})
    return res.json()

def test_create_user(client):
    res = client.post("/users", json={"name": "test"})
    assert res.status_code == 201

def test_get_user(client, created_user):
    res = client.get(f"/users/{created_user['id']}")
    assert res.status_code == 200

assertの検証が曖昧

# NG: 失敗時に何がおかしいか分からない
def test_process_data(sample_data):
    result = process(sample_data)
    assert result is not None
    assert len(result) > 0
# OK: 具体的な値を検証する
def test_process_data(sample_data):
    result = process(sample_data)
    assert result == {"status": "ok", "count": 3}

assert result is not Noneだけでは、返り値の中身が間違っていても検出できない。何を期待しているかを明確にしたassertを書くことで、失敗時のデバッグも早くなる。pytestは失敗時にassert式の各辺の値を表示してくれるので、具体的な比較を書くほどその恩恵を受けられる。

テスト対象がFastAPIアプリケーションの場合は、TestClientと組み合わせることでエンドポイントのテストも同じ流れで書ける。テスト可能な設計を最初から意識しておくと、後からテストを足す負担が大きく減る。

まとめ

  • pytestは関数ベースのシンプルな記法で、unittestに比べてテストのハードルが低い
  • フィクスチャで前処理・後処理を管理し、conftest.pyで複数ファイルから共有できる
  • @pytest.mark.parametrizeを使えば、入力パターンの違うテストを1関数にまとめられる
  • monkeypatchpytest-mockで外部依存のある関数も安全にテストできる
  • テスト間の暗黙の依存を排除し、各テストが独立して動くように設計するのが鉄則
  • assertは「何を期待しているか」を具体的に書くと、失敗時のデバッグが格段に楽になる

よくある質問(FAQ)

Q. pytestとunittestを混在させることはできる?

できます。pytestはunittestのTestCaseクラスもそのまま実行できる互換性を持っています。既存のunittestベースのテストスイートがある場合、pytestをテストランナーとして導入し、新規テストだけpytestスタイルで書く移行戦略がスムーズです。

Q. フィクスチャとsetUp/tearDownのどちらを使うべき?

pytestを採用しているなら、フィクスチャの方がおすすめです。フィクスチャは名前ベースで依存関係が明確になり、スコープの制御もしやすい設計になっています。setUp/tearDownはunittestとの互換性のために残されていますが、パラメータ化との組み合わせなどpytest固有の機能を活かしきれません。

Q. テストカバレッジはどうやって測定する?

pytest-covプラグインをインストールして、pytest --cov=srcのように実行します。--cov-report=htmlオプションでHTMLレポートを出力すると、どの行がカバーされていないか視覚的に確認できます。ただし、カバレッジの数値だけを追い求めるよりも、重要なビジネスロジックのテストが書けているかを意識する方が効果的です。型ヒントとmypyによる静的解析と組み合わせると、テストだけでは拾いきれない型レベルのバグも防げるのでおすすめです。

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!
目次