pytest

Pythonテスト単体テストテストフレームワークフィクスチャパラメータ化プラグイン

テストフレームワーク

pytest

概要

pytestは、Pythonにおける最も人気の高いテストフレームワークです。シンプルな構文で始められながら、強力なフィクスチャシステム、パラメータ化、豊富なプラグインエコシステムにより、あらゆる規模のプロジェクトに対応できる柔軟性を持っています。assert文の分析機能により詳細なエラー情報を提供し、初心者からエンタープライズレベルまで幅広く採用されています。

詳細

特徴

  • シンプルな構文: 関数ベースのテスト記述で学習コストが低い
  • 強力なフィクスチャシステム: テストデータの準備・クリーンアップを自動化
  • パラメータ化テスト: 同一ロジックを複数の入力値で効率的にテスト
  • 豊富なプラグインエコシステム: 800以上のプラグインで機能拡張
  • 詳細なアサーション: assert文の分析により具体的なエラー情報を表示
  • 柔軟な実行制御: マーカーによるテスト分類と選択実行
  • 並列実行サポート: pytest-xdistによる分散テスト実行

技術的背景

pytestは2004年に開発が始まり、Pythonテストの標準unitTestモジュールの制約を解決するために設計されました。関数ベースのアプローチにより、クラス継承の複雑さを回避し、より直感的なテスト記述を可能にしています。フィクスチャシステムは依存性注入パターンを採用し、テストの再利用性と保守性を大幅に向上させています。

エコシステム

pytest-covによるカバレッジ測定、pytest-djangoによるDjango統合、pytest-asyncioによる非同期テスト、pytest-bddによるBDD(振る舞い駆動開発)サポートなど、フレームワークやライブラリとの豊富な統合オプションが提供されています。

メリット・デメリット

メリット

  • 学習の容易さ: assert文とシンプルな関数で即座にテスト開始可能
  • 高い生産性: フィクスチャとパラメータ化により効率的なテスト作成
  • 優れた診断機能: 失敗時の詳細な情報表示でデバッグが容易
  • 拡張性: プラグインシステムによる柔軟なカスタマイズ
  • エンタープライズ対応: 大規模プロジェクトでの実績と安定性
  • 活発なコミュニティ: 継続的な機能追加とサポート

デメリット

  • 機能の多さ: 高度な機能の習得に時間が必要
  • プラグイン依存: 特定機能にプラグインが必要な場合がある
  • 実行速度: 小規模テストではunittestより若干重い場合がある
  • 設定の複雑さ: 大規模プロジェクトでの設定管理が複雑になる可能性

参考ページ

書き方の例

Hello World(基本テスト)

# test_basic.py
def test_addition():
    """基本的な加算テスト"""
    assert 2 + 3 == 5

def test_string_operations():
    """文字列操作のテスト"""
    text = "Hello, pytest!"
    assert text.startswith("Hello")
    assert "pytest" in text
    assert text.endswith("!")

# 実行: pytest test_basic.py

フィクスチャ使用例

# test_fixtures.py
import pytest
import tempfile
import os

@pytest.fixture
def sample_data():
    """テストデータを提供するフィクスチャ"""
    return {
        "users": ["Alice", "Bob", "Charlie"],
        "scores": [85, 92, 78]
    }

@pytest.fixture
def temp_file():
    """一時ファイルを作成・削除するフィクスチャ"""
    # セットアップ
    temp_fd, temp_path = tempfile.mkstemp()
    os.close(temp_fd)
    
    yield temp_path  # テストに渡す
    
    # クリーンアップ
    if os.path.exists(temp_path):
        os.unlink(temp_path)

def test_data_processing(sample_data):
    """フィクスチャを使用したテスト"""
    users = sample_data["users"]
    scores = sample_data["scores"]
    
    assert len(users) == len(scores)
    assert max(scores) == 92
    assert "Alice" in users

def test_file_operations(temp_file):
    """ファイル操作のテスト"""
    # ファイルに書き込み
    with open(temp_file, 'w') as f:
        f.write("Test content")
    
    # ファイルから読み込み
    with open(temp_file, 'r') as f:
        content = f.read()
    
    assert content == "Test content"

パラメータ化テスト

# test_parametrize.py
import pytest

@pytest.mark.parametrize("input_value,expected", [
    (2, 4),
    (3, 9),
    (4, 16),
    (5, 25),
])
def test_square(input_value, expected):
    """パラメータ化による複数ケーステスト"""
    assert input_value ** 2 == expected

@pytest.mark.parametrize("text,should_be_valid", [
    ("[email protected]", True),
    ("[email protected]", True),
    ("invalid-email", False),
    ("@domain.com", False),
    ("user@", False),
])
def test_email_validation(text, should_be_valid):
    """メールアドレス検証のパラメータ化テスト"""
    import re
    pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
    is_valid = bool(re.match(pattern, text))
    assert is_valid == should_be_valid

# 複数パラメータの組み合わせ
@pytest.mark.parametrize("x", [1, 2])
@pytest.mark.parametrize("y", [10, 20])
def test_multiplication(x, y):
    """複数パラメータの全組み合わせテスト"""
    result = x * y
    assert result > 0
    assert result == x * y

例外テスト

# test_exceptions.py
import pytest

def divide(a, b):
    """除算関数(ゼロ除算チェック付き)"""
    if b == 0:
        raise ValueError("Cannot divide by zero")
    return a / b

class CustomError(Exception):
    """カスタム例外クラス"""
    pass

def risky_function(value):
    """リスクのある処理をシミュレート"""
    if value < 0:
        raise CustomError("Negative values not allowed")
    if value == 0:
        raise ValueError("Zero is not valid")
    return value * 2

def test_zero_division():
    """ゼロ除算例外のテスト"""
    with pytest.raises(ValueError, match="Cannot divide by zero"):
        divide(10, 0)

def test_successful_division():
    """正常な除算のテスト"""
    result = divide(10, 2)
    assert result == 5.0

def test_custom_exception():
    """カスタム例外のテスト"""
    with pytest.raises(CustomError, match="Negative values not allowed"):
        risky_function(-1)

def test_multiple_exceptions():
    """複数の例外パターンのテスト"""
    # ValueError例外
    with pytest.raises(ValueError, match="Zero is not valid"):
        risky_function(0)
    
    # CustomError例外
    with pytest.raises(CustomError):
        risky_function(-5)

def test_exception_info():
    """例外情報の詳細確認"""
    with pytest.raises(ValueError) as excinfo:
        divide(1, 0)
    
    assert "Cannot divide by zero" in str(excinfo.value)
    assert excinfo.type == ValueError

マーキング(カスタムマーカー)

# test_markers.py
import pytest

# カスタムマーカーの定義(pytest.ini または conftest.py で)
# [pytest]
# markers =
#     slow: marks tests as slow
#     integration: marks tests as integration tests
#     unit: marks tests as unit tests
#     smoke: marks tests as smoke tests

@pytest.mark.unit
def test_fast_calculation():
    """高速な単体テスト"""
    assert 2 + 2 == 4

@pytest.mark.slow
def test_complex_calculation():
    """時間のかかるテスト"""
    import time
    time.sleep(0.1)  # 重い処理をシミュレート
    result = sum(range(1000))
    assert result == 499500

@pytest.mark.integration
def test_database_connection():
    """統合テスト(模擬)"""
    # データベース接続のシミュレート
    connection_status = True
    assert connection_status

@pytest.mark.smoke
def test_basic_functionality():
    """スモークテスト"""
    assert True

@pytest.mark.parametrize("env", ["development", "staging", "production"])
@pytest.mark.integration
def test_environment_specific(env):
    """環境別テスト"""
    assert env in ["development", "staging", "production"]

# 実行例:
# pytest -m "unit"                    # 単体テストのみ実行
# pytest -m "not slow"               # 遅いテスト以外を実行
# pytest -m "integration or smoke"   # 統合テストとスモークテストを実行

プラグイン活用

# conftest.py - プロジェクト共通設定
import pytest
import logging

# カスタムフィクスチャ
@pytest.fixture(scope="session")
def api_client():
    """APIクライアントのセッションフィクスチャ"""
    class MockAPIClient:
        def __init__(self):
            self.base_url = "https://api.example.com"
            self.connected = True
        
        def get(self, endpoint):
            return {"status": "success", "data": f"Data from {endpoint}"}
        
        def post(self, endpoint, data):
            return {"status": "created", "id": 123}
        
        def close(self):
            self.connected = False
    
    client = MockAPIClient()
    yield client
    client.close()

@pytest.fixture
def logger():
    """ロガーフィクスチャ"""
    logging.basicConfig(level=logging.INFO)
    return logging.getLogger(__name__)

# カスタムマーカー設定
def pytest_configure(config):
    """pytest設定のカスタマイズ"""
    config.addinivalue_line(
        "markers", "api: mark test as API test"
    )
    config.addinivalue_line(
        "markers", "database: mark test as database test"
    )

# テストファイル: test_plugins.py
import pytest

@pytest.mark.api
def test_api_get(api_client, logger):
    """APIプラグインを使用したテスト"""
    logger.info("Testing API GET request")
    response = api_client.get("/users")
    
    assert response["status"] == "success"
    assert "data" in response

@pytest.mark.api
def test_api_post(api_client):
    """APIプラグインを使用したPOSTテスト"""
    data = {"name": "New User", "email": "[email protected]"}
    response = api_client.post("/users", data)
    
    assert response["status"] == "created"
    assert response["id"] == 123

# pytest-cov使用例(カバレッジ測定)
# pip install pytest-cov
# 実行: pytest --cov=mymodule --cov-report=html

# pytest-xdist使用例(並列実行)
# pip install pytest-xdist
# 実行: pytest -n 4  # 4プロセスで並列実行

# pytest-mock使用例(モック機能拡張)
# pip install pytest-mock
def test_with_mock(mocker):
    """pytest-mockを使用したモックテスト"""
    mock_function = mocker.patch('mymodule.expensive_function')
    mock_function.return_value = "mocked result"
    
    # テスト実行
    from mymodule import use_expensive_function
    result = use_expensive_function()
    
    assert result == "mocked result"
    mock_function.assert_called_once()