pytestで効果的なテストを書く: fixture、parametrize、モック

Shunku

pytestはPythonの事実上の標準テストフレームワークです。シンプルな構文、強力なfixture、豊富なプラグインにより、効果的なテストを効率的に書けます。

pytestの基本

インストール

pip install pytest
# または
uv add --dev pytest

最初のテスト

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

def test_add():
    assert add(1, 2) == 3
    assert add(-1, 1) == 0
    assert add(0, 0) == 0
# テスト実行
pytest

# 詳細出力
pytest -v

# 特定のファイル
pytest test_calculator.py

# 特定のテスト
pytest test_calculator.py::test_add

assertの使い方

def test_assertions():
    # 等価性
    assert 1 + 1 == 2

    # 真偽値
    assert True
    assert not False

    # 含む/含まない
    assert "hello" in "hello world"
    assert 3 not in [1, 2]

    # 例外を期待
    import pytest
    with pytest.raises(ValueError):
        int("not a number")

    # 例外メッセージをチェック
    with pytest.raises(ValueError, match="invalid literal"):
        int("not a number")

Fixture

基本的なfixture

import pytest

@pytest.fixture
def sample_user():
    """テスト用のユーザーデータ"""
    return {"id": 1, "name": "Alice", "email": "alice@example.com"}

def test_user_name(sample_user):
    assert sample_user["name"] == "Alice"

def test_user_email(sample_user):
    assert "@" in sample_user["email"]

fixtureのスコープ

@pytest.fixture(scope="function")  # デフォルト: 各テストで新規作成
def per_test_resource():
    return create_resource()

@pytest.fixture(scope="class")  # クラス内で共有
def per_class_resource():
    return create_resource()

@pytest.fixture(scope="module")  # モジュール内で共有
def per_module_resource():
    return create_resource()

@pytest.fixture(scope="session")  # セッション全体で共有
def per_session_resource():
    return create_resource()

セットアップとクリーンアップ

import pytest

@pytest.fixture
def database_connection():
    # セットアップ
    conn = create_connection()

    yield conn  # テストに値を渡す

    # クリーンアップ
    conn.close()

def test_query(database_connection):
    result = database_connection.execute("SELECT 1")
    assert result == 1

conftest.py

プロジェクト全体で共有するfixtureを定義:

# conftest.py
import pytest

@pytest.fixture
def api_client():
    """APIクライアントをテスト全体で共有"""
    from my_app import create_client
    return create_client(base_url="http://test-api.local")

@pytest.fixture
def test_user(api_client):
    """テストユーザーを作成し、テスト後に削除"""
    user = api_client.create_user(name="Test User")
    yield user
    api_client.delete_user(user.id)

Parametrize

複数のケースをテスト

import pytest

@pytest.mark.parametrize("input,expected", [
    (1, 1),
    (2, 4),
    (3, 9),
    (4, 16),
])
def test_square(input, expected):
    assert input ** 2 == expected

複数パラメータの組み合わせ

@pytest.mark.parametrize("x", [1, 2])
@pytest.mark.parametrize("y", [3, 4])
def test_multiply(x, y):
    # (1,3), (1,4), (2,3), (2,4) の4パターン
    assert x * y == x * y

条件付きスキップ

import sys
import pytest

@pytest.mark.parametrize("value,expected", [
    (1, "one"),
    pytest.param(2, "two", marks=pytest.mark.skip(reason="未実装")),
    pytest.param(3, "three", marks=pytest.mark.skipif(
        sys.version_info < (3, 11),
        reason="Python 3.11以上が必要"
    )),
])
def test_number_to_word(value, expected):
    assert number_to_word(value) == expected

モック

unittest.mockの使用

from unittest.mock import Mock, patch, MagicMock

def test_with_mock():
    # Mockオブジェクト
    mock_api = Mock()
    mock_api.get_user.return_value = {"id": 1, "name": "Alice"}

    result = mock_api.get_user(1)
    assert result["name"] == "Alice"
    mock_api.get_user.assert_called_once_with(1)

def test_with_patch():
    with patch("my_module.requests.get") as mock_get:
        mock_get.return_value.json.return_value = {"status": "ok"}

        from my_module import fetch_data
        result = fetch_data("http://example.com")

        assert result["status"] == "ok"

pytest-mockプラグイン

# pip install pytest-mock

def test_with_mocker(mocker):
    mock_api = mocker.patch("my_module.api_client")
    mock_api.get_user.return_value = {"id": 1}

    from my_module import get_user_name
    result = get_user_name(1)

    mock_api.get_user.assert_called_once_with(1)

fixtureとモックの組み合わせ

import pytest
from unittest.mock import Mock

@pytest.fixture
def mock_database():
    db = Mock()
    db.query.return_value = [
        {"id": 1, "name": "Item 1"},
        {"id": 2, "name": "Item 2"},
    ]
    return db

def test_list_items(mock_database):
    from my_app import ItemService
    service = ItemService(mock_database)

    items = service.list_all()

    assert len(items) == 2
    mock_database.query.assert_called_once()

非同期テスト

pytest-asyncio

pip install pytest-asyncio
import pytest
import asyncio

@pytest.mark.asyncio
async def test_async_function():
    result = await async_fetch_data()
    assert result == "expected"

@pytest.mark.asyncio
async def test_async_with_timeout():
    async with asyncio.timeout(1.0):
        result = await slow_operation()
        assert result is not None

非同期fixture

import pytest

@pytest.fixture
async def async_client():
    client = await create_async_client()
    yield client
    await client.close()

@pytest.mark.asyncio
async def test_with_async_fixture(async_client):
    response = await async_client.get("/users")
    assert response.status == 200

テストの整理

マーカー

import pytest

@pytest.mark.slow
def test_heavy_computation():
    """時間のかかるテスト"""
    pass

@pytest.mark.integration
def test_database_connection():
    """統合テスト"""
    pass

@pytest.mark.skip(reason="一時的にスキップ")
def test_broken_feature():
    pass

@pytest.mark.xfail(reason="既知のバグ")
def test_known_bug():
    pass
# マーカーでフィルタ
pytest -m "not slow"
pytest -m "integration"

pytest.ini / pyproject.toml

# pyproject.toml
[tool.pytest.ini_options]
testpaths = ["tests"]
python_files = ["test_*.py"]
python_classes = ["Test*"]
python_functions = ["test_*"]
addopts = "-v --tb=short"
markers = [
    "slow: marks tests as slow",
    "integration: marks tests as integration tests",
]

カバレッジ

pytest-cov

pip install pytest-cov
# カバレッジレポート
pytest --cov=my_package

# HTML出力
pytest --cov=my_package --cov-report=html

# 最小カバレッジを要求
pytest --cov=my_package --cov-fail-under=80
# pyproject.toml
[tool.coverage.run]
source = ["src"]
omit = ["*/tests/*", "*/__pycache__/*"]

[tool.coverage.report]
exclude_lines = [
    "pragma: no cover",
    "if TYPE_CHECKING:",
    "raise NotImplementedError",
]

実践的なパターン

テストの構造

tests/
├── conftest.py          # 共有fixture
├── unit/                # ユニットテスト
│   ├── test_models.py
│   └── test_services.py
├── integration/         # 統合テスト
│   └── test_api.py
└── e2e/                 # E2Eテスト
    └── test_workflow.py

Given-When-Then パターン

def test_user_registration():
    # Given: 前提条件
    user_data = {"email": "test@example.com", "password": "secure123"}

    # When: 実行
    result = register_user(user_data)

    # Then: 検証
    assert result.success is True
    assert result.user.email == "test@example.com"

ファクトリfixture

import pytest

@pytest.fixture
def user_factory():
    """テストユーザーを生成するファクトリ"""
    created_users = []

    def create(name: str = "Test User", email: str = None):
        email = email or f"{name.lower().replace(' ', '.')}@test.com"
        user = User(name=name, email=email)
        created_users.append(user)
        return user

    yield create

    # クリーンアップ
    for user in created_users:
        user.delete()

def test_multiple_users(user_factory):
    alice = user_factory("Alice")
    bob = user_factory("Bob")

    assert alice.email != bob.email

まとめ

pytestの主要機能:

flowchart TB
    subgraph Core["コア機能"]
        C1["assert"]
        C2["テスト検出"]
        C3["マーカー"]
    end

    subgraph Fixture["Fixture"]
        F1["セットアップ/クリーンアップ"]
        F2["スコープ"]
        F3["conftest.py"]
    end

    subgraph Advanced["高度な機能"]
        A1["parametrize"]
        A2["モック"]
        A3["非同期テスト"]
    end

    style Core fill:#3b82f6,color:#fff
    style Fixture fill:#22c55e,color:#fff
    style Advanced fill:#8b5cf6,color:#fff
機能 用途
@pytest.fixture テストデータ・リソースの準備
@pytest.mark.parametrize 複数ケースのテスト
conftest.py 共有fixtureの定義
mocker / patch 依存関係のモック
@pytest.mark.asyncio 非同期テスト
pytest-cov カバレッジ計測

主要な原則:

  • fixtureを活用: 重複を減らし、テストを読みやすく
  • parametrizeでケースを網羅: 1つのテストで複数のケースをカバー
  • 適切なスコープ: リソースの作成コストに応じてスコープを選択
  • モックは必要最小限: 過度なモックはテストの価値を下げる
  • カバレッジを意識: ただし100%を目標にしない

pytestは、シンプルな構文で強力なテストを書けるPython開発者の必須ツールです。

参考資料