Pytest
· 約7分
- pytest を使うことで、テストコードを簡潔かつ可読性の高いものにできる
- フィクスチャを活用することで、テストの前提条件の準備や後処理を柔軟に記述可能
- デコレーターによってデータ駆動テストをシンプルに記述できる
- 詳細なテスト結果を提供し、デバッグが容易
- 豊富なプラグインエコシステムにより、機能の拡張も容易
はじめに
Jest や JUnit などの他のテストフレームワークと比べて、pytest の導入は非常にシンプルです。
インストール
pip install -U pytest
セットアップ
pytest は、以下の簡単なルールに基づいてテストケースを自動的に検出します:
- テストファイル名:test_ で始まるか、_test.py で終わるファイル
- テスト関数/クラス:test_ で始まる関数、または Test で始まるクラスの中にある関数(ただし init は不可)
特別な設定ファイルなどは基本的に不要で、すぐにテストコードの記述を始められます。
一般的には、プロジェクトルートに tests/ ディレクトリを作成してテストコードを配置します。
my_project/
├── my_package/
│ ├── __init__.py
│ ├── module_a.py
│ └── module_b.py
├── tests/
│ ├── __init__.py # Can be empty, but often present
│ ├── unit/ # Optional: subdirectories for different test types
│ │ ├── test_module_a_unit.py
│ │ └── test_module_b_unit.py
│ ├── integration/ # Optional: for integration tests
│ │ └── test_api_integration.py
│ ├── functional/ # Optional: for end-to-end tests
│ │ └── test_user_flow.py
│ └── conftest.py # For shared fixtures (more on this below)
├── README.md
└── setup.py
テストの書き方
基本は assert を使って期待値を検証
# test_calculations.py
from my_functions import add, subtract, multiply
def test_add_positive_numbers():
assert add(2, 3) == 5
def test_add_negative_numbers():
assert add(-1, -5) == -6
@pytest.fixture を使った前処理・後処理の共通化
目的
- テスト実行前の初期状態の準備(例:DB接続、設定ファイル、モックの準備)
- テスト後のクリーンアップ処理(例:接続の切断、一時ファイル削除)
- 共通処理の再利用による重複の排除
定義方法
- 通常の Python 関数に @pytest.fixture をつけて定義します
- yield の前に前処理、後に後処理を記述します
- 使用する側では引数にフィクスチャ名を指定するだけで自動的に注入されます
利用方法
- フィクスチャを使用したいテスト関数や他のフィクスチャの引数に、定義したフィクスチャ関数名を指定するだけです。Pytestが自動的に依存性を解決し、フィクスチャが提供する値を注入してくれます。
スコープの指定
フィクスチャはデフォルトで function スコープですが、scope 引数でその実行頻度を変更できます。
- function (デフォルト): 各テスト関数ごとに1回実行。
- class: テストクラス内の全テストメソッドに対して1回実行。
- module: モジュール(ファイル)内の全テストに対して1回実行。
- session: テストセッション全体で1回だけ実行。
フィクスチャの依存関係
フィクスチャは他のフィクスチャを引数として受け取ることができます。
conftest.py を使ったフィクスチャの共有
フィクスチャは、conftest.py という特別なファイルに定義することで、複数のテストファイル間で共有することができます。
conftest.py に定義されたフィクスチャは、明示的なインポートなしに、同じディレクトリやサブディレクトリ内のすべてのテストファイルから利用可能です。これにより、プロジェクト全体で共通のセットアップロジックを一元管理できます。
@pytest.fixture(scope="module") # モジュールスコープに設定
def module_db_connection():
"""モジュール内で一度だけ実行されるDB接続のフィクスチャ"""
print("\n[Module Scope] DB接続を確立しました。")
db_conn = {"status": "connected", "data": []}
yield db_conn
print("[Module Scope] DB接続を切断しました。")
@pytest.fixture
def empty_db():
"""A fixture that provides an empty dictionary, simulating an empty database."""
print("\nSetting up empty_db...") # This will print during test execution
_mock_db.clear() # Ensure it's empty before each test using this fixture
yield _mock_db # Yield the resource to the test
print("Tearing down empty_db...") # This runs after the test finishes
_mock_db.clear() # Clean up after the test
@pytest.fixture
def populated_db():
"""A fixture that provides a populated dictionary, simulating a database with some data."""
print("\nSetting up populated_db...")
_mock_db.clear()
_mock_db["user1"] = {"name": "Alice", "email": "[email protected]"}
_mock_db["user2"] = {"name": "Bob", "email": "[email protected]"}
yield _mock_db
print("Tearing down populated_db...")
_mock_db.clear()
def test_add_user_to_empty_db(module_db_connection, empty_db):
"""Test adding a user to an initially empty database."""
print("Running test_add_user_to_empty_db...")
empty_db["user3"] = {"name": "Charlie", "email": "[email protected]"}
assert "user3" in empty_db
assert len(empty_db) == 1
def test_retrieve_user_from_populated_db(populated_db):
"""Test retrieving an existing user from a populated database."""
print("Running test_retrieve_user_from_populated_db...")
user = populated_db.get("user1")
assert user is not None
assert user["name"] == "Alice"
assert user["email"] == "[email protected]"
@pytest.mark.parametrize でデータ駆動テスト
同じロジックを異なるデータセットで繰り返しテストしたい場合、parametrize を使えばコードを繰り返す必要がありません。
import pytest
def is_palindrome(s):
"""Checks if a string is a palindrome."""
cleaned_s = "".join(filter(str.isalnum, s)).lower()
return cleaned_s == cleaned_s[::-1]
@pytest.mark.parametrize("input_string, expected_output", [
("racecar", True),
("madam", True),
("A man, a plan, a canal: Panama", True), # With punctuation and spaces
("hello", False),
("Python", False),
("", True), # Empty string is a palindrome
("a", True), # Single character is a palindrome
])
def test_is_palindrome(input_string, expected_output):
"""Test the is_palindrome function with various inputs."""
assert is_palindrome(input_string) == expected_output
その他
テストをスキップする方法。 実際のプロジェクトを開発する際に、テストをスキップするのは、実務上たまに使ってるユースケースです。
Pytestを使ったら簡単にできます。
# test_feature_status.py
import pytest
def divide(a, b):
if b == 0:
raise ZeroDivisionError("Cannot divide by zero")
return a / b
@pytest.mark.skip(reason="This feature is not yet implemented")
def test_new_feature_logic():
"""A test for a feature that's still under development."""
assert 1 == 2 # This test would fail, but it's skipped
@pytest.mark.skipif(
pytest.__version__ < "8.0",
reason="Requires pytest version 8.0 or higher"
)
def test_new_pytest_feature():
"""This test only runs if a specific Pytest version is met."""
assert True
@pytest.mark.xfail(reason="Bug #1234: Division by zero is not handled gracefully yet")
def test_divide_by_zero_xfail():
"""This test is expected to fail due to a known bug."""
assert divide(10, 0) == 0 # This will raise ZeroDivisionError, but it's xfailed
総評
pytest はシンプルながらも強力な機能を持ち、テストコードの質を大きく向上させることができます。特にフィクスチャやパラメータ化テストを活用することで、実用的で保守性の高いテストが可能です。