Python テストパターン は何をしますか?
pytest、TDD手法、ベストプラクティスを用いたPythonアプリケーション向けの包括的なテスト戦略。
いつアクティブにするか
- 新しいPythonコードを書くとき(TDD: レッド、グリーン、リファクターに従う)
- Pythonプロジェクトのテストスイートを設計するとき
- Pythonテストのカバレッジをレビューするとき
- テストインフラストラクチャをセットアップするとき
コアテスト哲学
テスト駆動開発 (TDD)
常にTDDサイクルに従う:
- RED: 望ましい振る舞いに対して失敗するテストを書く
- GREEN: テストをパスさせるための最小限のコードを書く
- REFACTOR: テストが緑のままコードを改善する
# ステップ1: 失敗するテストを書く (RED)
def test_add_numbers():
result = add(2, 3)
assert result == 5
# ステップ2: 最小限の実装を書く (GREEN)
def add(a, b):
return a + b
# ステップ3: 必要に応じてリファクタリング (REFACTOR)
カバレッジ要件
- 目標: 80%以上のコードカバレッジ
- クリティカルパス: 100%のカバレッジ必須
- カバレッジの測定には
pytest --covを使用
pytest --cov=mypackage --cov-report=term-missing --cov-report=html
pytestの基礎
基本的なテスト構造
import pytest
def test_addition():
"""基本的な加算をテスト。"""
assert 2 + 2 == 4
def test_string_uppercase():
"""文字列の大文字変換をテスト。"""
text = "hello"
assert text.upper() == "HELLO"
def test_list_append():
"""リストへの追加をテスト。"""
items = [1, 2, 3]
items.append(4)
assert 4 in items
assert len(items) == 4
アサーション
# 等価性
assert result == expected
# 非等価性
assert result != unexpected
# 真偽値
assert result # 真と評価される
assert not result # 偽と評価される
assert result is True # 厳密にTrue
assert result is False # 厳密にFalse
assert result is None # 厳密にNone
# メンバーシップ
assert item in collection
assert item not in collection
# 比較
assert result > 0
assert 0 <= result <= 100
# 型チェック
assert isinstance(result, str)
# 例外テスト(推奨される方法)
with pytest.raises(ValueError):
raise ValueError("エラーメッセージ")
# 例外メッセージのチェック
with pytest.raises(ValueError, match="invalid input"):
raise ValueError("invalid input provided")
# 例外属性のチェック
with pytest.raises(ValueError) as exc_info:
raise ValueError("エラーメッセージ")
assert str(exc_info.value) == "エラーメッセージ"
フィクスチャ
基本的なフィクスチャの使用
import pytest
@pytest.fixture
def sample_data():
"""サンプルデータを提供するフィクスチャ。"""
return {"name": "Alice", "age": 30}
def test_sample_data(sample_data):
"""フィクスチャを使用したテスト。"""
assert sample_data["name"] == "Alice"
assert sample_data["age"] == 30
Setup/Teardown付きフィクスチャ
@pytest.fixture
def database():
"""Setupとteardown付きフィクスチャ。"""
# Setup
db = Database(":memory:")
db.create_tables()
db.insert_test_data()
yield db # テストに提供
# Teardown
db.close()
def test_database_query(database):
"""データベース操作をテスト。"""
result = database.query("SELECT * FROM users")
assert len(result) > 0
フィクスチャのスコープ
# 関数スコープ(デフォルト) - テストごとに実行
@pytest.fixture
def temp_file():
with open("temp.txt", "w") as f:
yield f
os.remove("temp.txt")
# モジュールスコープ - モジュールごとに1回実行
@pytest.fixture(scope="module")
def module_db():
db = Database(":memory:")
db.create_tables()
yield db
db.close()
# セッションスコープ - テストセッションごとに1回実行
@pytest.fixture(scope="session")
def shared_resource():
resource = ExpensiveResource()
yield resource
resource.cleanup()
パラメータ化されたフィクスチャ
@pytest.fixture(params=[1, 2, 3])
def number(request):
"""パラメータ化されたフィクスチャ。"""
return request.param
def test_numbers(number):
"""テストは各パラメータに対して1回ずつ、合計3回実行される。"""
assert number > 0
複数のフィクスチャの使用
@pytest.fixture
def user():
return User(id=1, name="Alice")
@pytest.fixture
def admin():
return User(id=2, name="Admin", role="admin")
def test_user_admin_interaction(user, admin):
"""複数のフィクスチャを使用したテスト。"""
assert admin.can_manage(user)
自動使用フィクスチャ
@pytest.fixture(autouse=True)
def reset_config():
"""すべてのテストの前に自動的に実行される。"""
Config.reset()
yield
Config.cleanup()
def test_without_fixture_call():
# reset_config が自動的に実行される
assert Config.get_setting("debug") is False
共有フィクスチャのためのconftest.py
# tests/conftest.py
import pytest
@pytest.fixture
def client():
"""すべてのテストで共有されるフィクスチャ。"""
app = create_app(testing=True)
with app.test_client() as client:
yield client
@pytest.fixture
def auth_headers(client):
"""APIテスト用の認証ヘッダーを生成。"""
response = client.post("/api/login", json={
"username": "test",
"password": "test"
})
token = response.json["token"]
return {"Authorization": f"Bearer {token}"}
パラメータ化
基本的なパラメータ化
@pytest.mark.parametrize("input,expected", [
("hello", "HELLO"),
("world", "WORLD"),
("PyThOn", "PYTHON"),
])
def test_uppercase(input, expected):
"""テストは異なる入力で3回実行される。"""
assert input.upper() == expected
複数パラメータ
@pytest.mark.parametrize("a,b,expected", [
(2, 3, 5),
(0, 0, 0),
(-1, 1, 0),
(100, 200, 300),
])
def test_add(a, b, expected):
"""複数の入力で加算をテスト。"""
assert add(a, b) == expected
ID付きパラメータ化
@pytest.mark.parametrize("input,expected", [
("[email protected]", True),
("invalid", False),
("@no-domain.com", False),
], ids=["valid-email", "missing-at", "missing-domain"])
def test_email_validation(input, expected):
"""読みやすいテストIDでメール検証をテスト。"""
assert is_valid_email(input) is expected
パラメータ化されたフィクスチャ
@pytest.fixture(params=["sqlite", "postgresql", "mysql"])
def db(request):
"""複数のデータベースバックエンドでテスト。"""
if request.param == "sqlite":
return Database(":memory:")
elif request.param == "postgresql":
return Database("postgresql://localhost/test")
elif request.param == "mysql":
return Database("mysql://localhost/test")
def test_database_operations(db):
"""テストは各データベースに対して1回ずつ、合計3回実行される。"""
result = db.query("SELECT 1")
assert result is not None
マーカーとテスト選択
カスタムマーカー
# 遅いテストをマーク
@pytest.mark.slow
def test_slow_operation():
time.sleep(5)
# 統合テストをマーク
@pytest.mark.integration
def test_api_integration():
response = requests.get("https://api.example.com")
assert response.status_code == 200
# ユニットテストをマーク
@pytest.mark.unit
def test_unit_logic():
assert calculate(2, 3) == 5
特定のテストを実行
# 高速テストのみ実行
pytest -m "not slow"
# 統合テストのみ実行
pytest -m integration
# 統合テストまたは遅いテストを実行
pytest -m "integration or slow"
# ユニットテストとしてマークされ、遅くないテストを実行
pytest -m "unit and not slow"
pytest.iniでマーカーを設定
[pytest]
markers =
slow: テストを遅いとしてマーク
integration: テストを統合テストとしてマーク
unit: テストをユニットテストとしてマーク
django: テストがDjangoを必要とすることをマーク
モックとパッチ
関数のモック化
from unittest.mock import patch, Mock
@patch("mypackage.external_api_call")
def test_with_mock(api_call_mock):
"""モック化された外部APIでテスト。"""
api_call_mock.return_value = {"status": "success"}
result = my_function()
api_call_mock.assert_called_once()
assert result["status"] == "success"
戻り値のモック化
@patch("mypackage.Database.connect")
def test_database_connection(connect_mock):
"""モック化されたデータベース接続でテスト。"""
connect_mock.return_value = MockConnection()
db = Database()
db.connect()
connect_mock.assert_called_once_with("localhost")
例外のモック化
@patch("mypackage.api_call")
def test_api_error_handling(api_call_mock):
"""モック化された例外でエラーハンドリングをテスト。"""
api_call_mock.side_effect = ConnectionError("Network error")
with pytest.raises(ConnectionError):
api_call()
api_call_mock.assert_called_once()
コンテキストマネージャのモック化
@patch("builtins.open", new_callable=mock_open)
def test_file_reading(mock_file):
"""モック化されたopenでファイル読み取りをテスト。"""
mock_file.return_value.read.return_value = "file content"
result = read_file("test.txt")
mock_file.assert_called_once_with("test.txt", "r")
assert result == "file content"
オートスペックの使用
@patch("mypackage.DBConnection", autospec=True)
def test_autospec(db_mock):
"""APIの誤用を検出するためにautospecを使用してテスト。"""
db = db_mock.return_value
db.query("SELECT * FROM users")
# DBConnectionにqueryメソッドがない場合、これは失敗する
db_mock.assert_called_once()
クラスインスタンスのモック化
class TestUserService:
@patch("mypackage.UserRepository")
def test_create_user(self, repo_mock):
"""モック化されたリポジトリでユーザー作成をテスト。"""
repo_mock.return_value.save.return_value = User(id=1, name="Alice")
service = UserService(repo_mock.return_value)
user = service.create_user(name="Alice")
assert user.name == "Alice"
repo_mock.return_value.save.assert_called_once()
プロパティのモック化
@pytest.fixture
def mock_config():
"""プロパティを持つモックを作成。"""
config = Mock()
type(config).debug = PropertyMock(return_value=True)
type(config).api_key = PropertyMock(return_value="test-key")
return config
def test_with_mock_config(mock_config):
"""モック化された設定プロパティでテスト。"""
assert mock_config.debug is True
assert mock_config.api_key == "test-key"
非同期コードのテスト
pytest-asyncioを使った非同期テスト
import pytest
@pytest.mark.asyncio
async def test_async_function():
"""非同期関数をテスト。"""
result = await async_add(2, 3)
assert result == 5
@pytest.mark.asyncio
async def test_async_with_fixture(async_client):
"""非同期フィクスチャを使った非同期テスト。"""
response = await async_client.get("/api/users")
assert response.status_code == 200
非同期フィクスチャ
@pytest.fixture
async def async_client():
"""非同期テストクライアントを提供する非同期フィクスチャ。"""
app = create_app()
async with app.test_client() as client:
yield client
@pytest.mark.asyncio
async def test_api_endpoint(async_client):
"""非同期フィクスチャを使用したテスト。"""
response = await async_client.get("/api/data")
assert response.status_code == 200
非同期関数のモック化
@pytest.mark.asyncio
@patch("mypackage.async_api_call")
async def test_async_mock(api_call_mock):
"""モックを使った非同期関数のテスト。"""
api_call_mock.return_value = {"status": "ok"}
result = await my_async_function()
api_call_mock.assert_awaited_once()
assert result["status"] == "ok"
例外のテスト
期待される例外のテスト
def test_divide_by_zero():
"""ゼロ除算がZeroDivisionErrorを発生させることをテスト。"""
with pytest.raises(ZeroDivisionError):
divide(10, 0)
def test_custom_exception():
"""メッセージ付きのカスタム例外をテスト。"""
with pytest.raises(ValueError, match="invalid input"):
validate_input("invalid")
例外属性のテスト
def test_exception_with_details():
"""カスタム属性を持つ例外をテスト。"""
with pytest.raises(CustomError) as exc_info:
raise CustomError("error", code=400)
assert exc_info.value.code == 400
assert "error" in str(exc_info.value)
副作用のテスト
ファイル操作のテスト
import tempfile
import os
def test_file_processing():
"""一時ファイルを使ったファイル処理のテスト。"""
with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.txt') as f:
f.write("test content")
temp_path = f.name
try:
result = process_file(temp_path)
assert result == "processed: test content"
finally:
os.unlink(temp_path)
pytestのtmp_pathフィクスチャを使ったテスト
def test_with_tmp_path(tmp_path):
"""pytest組み込みの一時パスフィクスチャを使ったテスト。"""
test_file = tmp_path / "test.txt"
test_file.write_text("hello world")
result = process_file(str(test_file))
assert result == "hello world"
# tmp_path は自動的にクリーンアップされる
tmpdirフィクスチャを使ったテスト
def test_with_tmpdir(tmpdir):
"""pytestのtmpdirフィクスチャを使ったテスト。"""
test_file = tmpdir.join("test.txt")
test_file.write("data")
result = process_file(str(test_file))
assert result == "data"
テストの構成
ディレクトリ構造
tests/
├── conftest.py # 共有フィクスチャ
├── __init__.py
├── unit/ # ユニットテスト
│ ├── __init__.py
│ ├── test_models.py
│ ├── test_utils.py
│ └── test_services.py
├── integration/ # 統合テスト
│ ├── __init__.py
│ ├── test_api.py
│ └── test_database.py
└── e2e/ # エンドツーエンドテスト
├── __init__.py
└── test_user_flow.py
テストクラス
class TestUserService:
"""関連するテストをクラスにグループ化。"""
@pytest.fixture(autouse=True)
def setup(self):
"""このクラスの各テストの前にsetupが実行される。"""
self.service = UserService()
def test_create_user(self):
"""ユーザー作成をテスト。"""
user = self.service.create_user("Alice")
assert user.name == "Alice"
def test_delete_user(self):
"""ユーザー削除をテスト。"""
self.service.create_user("Bob")
result = self.service.delete_user("Bob")
assert result is True
def test_get_nonexistent_user(self):
"""存在しないユーザーの取得をテスト。"""
user = self.service.get_user("Charlie")
assert user is None
高度なフィクスチャパターン
条件付きフィクスチャ
import pytest
@pytest.fixture
def api_client():
"""環境に応じてAPIクライアントを返す。"""
if os.getenv("CI"):
return RealAPIClient()
else:
return MockAPIClient()
def test_api_call(api_client):
"""条件付きフィクスチャを使ったテスト。"""
response = api_client.get("/api/data")
assert response.status_code == 200
リクエストオブジェクトを使ったフィクスチャ
@pytest.fixture
def user(request):
"""リクエストマーカーに基づいて異なるユーザーを返す。"""
markers = request.node.get_closest_marker("user_role")
if markers:
role = markers.args[0]
return User(role=role)
return User(role="guest")
@pytest.mark.user_role("admin")
def test_admin_access(user):
"""管理者アクセスをテスト。"""
assert user.has_permission("delete")
@pytest.mark.user_role("editor")
def test_editor_access(user):
"""編集者アクセスをテスト。"""
assert user.has_permission("edit")
カバレッジの設定
.coveragerc
[run]
source = mypackage
omit =
*/tests/*
*/venv/*
*/migrations/*
[report]
exclude_lines =
pragma: no cover
def __repr__
if self.debug:
if __name__ == .__main__.:
pyproject.tomlでのカバレッジ設定
[tool.coverage.run]
source = ["mypackage"]
omit = ["*/tests/*", "*/venv/*", "*/migrations/*"]
[tool.coverage.report]
exclude_lines = [
"pragma: no cover",
"def __repr__",
"if self.debug:",
"if __name__ == .__main__.:",
]
テスト実行設定
pytest.ini
[pytest]
minversion = 6.0
addopts = -ra -q --strict-markers
--cov=mypackage --cov-report=term-missing
--tb=short
testpaths = tests
markers =
slow: 遅いテスト (例: ネットワーク呼び出し)
integration: 統合テスト
unit: ユニットテスト
smoke: スモークテスト
テストの実行コマンド
# すべてのテストを実行
pytest
# 詳細な出力で実行
pytest -v
# 特定のテストファイルを実行
pytest tests/unit/test_models.py
# 特定のテスト関数を実行
pytest tests/unit/test_models.py::test_user_creation
# 特定のマーカーでフィルタリング
pytest -m "unit and not slow"
# カバレッジレポート付き
pytest --cov=mypackage --cov-report=html
# 失敗したテストでデバッガを起動
pytest --pdb
CI/CDでのテスト
GitHub Actions Workflow例
name: Run Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.10", "3.11", "3.12"]
steps:
- uses: actions/checkout@v4
- name: Python ${{ matrix.python-version }} をセットアップ
uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python-version }}
- name: 依存関係をインストール
run: |
python -m pip install --upgrade pip
pip install -r requirements-dev.txt
- name: テストを実行
run: |
pytest --cov=mypackage --cov-report=xml
- name: カバレッジレポートをアップロード
uses: codecov/codecov-action@v3
with:
file: ./coverage.xml
ベストプラクティス
- テスト名は説明的に: テストが何をテストしているかを明確に示す
- アサーションごとに1つの概念: 各テストは1つの概念を検証する
- フィクスチャを効果的に使う: 重複を避けるためにsetUp/tearDownにフィクスチャを使う
- モックは控えめに: 外部APIのみをモックし、内部ロジックはモックしない
- テストを高速に保つ: 高速なテストは頻繁に実行できる
- テストは独立させる: テストは他のテストに依存してはいけない
- コードと一緒にテストを書く: プロジェクトのコードと同時にテストも書く
- カバレッジ目標を守る: 80%以上のカバレッジを目標とし、クリティカルパスは100%にする
- 適切なアサーションメソッドを使う: アサーションに適した方法を使用する
- パラメータ化を使って網羅する: パラメータ化で多くの入力をテストする
避けるべきアンチパターン
# 悪い例: 1つのテストに複数のアサーション
@pytest.mark.parametrize("input,expected", [
("hello", "HELLO"),
("world", "WORLD"),
])
def test_uppercase(input, expected):
"""テストは異なる入力で3回実行される。"""
assert input.upper() == expected
避けるべきアンチパターン
# 悪い例: 1つのテストに複数のアサーション
def test_user_operations():
user = create_user("Alice")
assert user.name == "Alice"
assert user.email == "[email protected]"
assert user.age == 30
# 良い例: アサーションごとに1つのテスト
def test_user_name():
user = create_user("Alice")
assert user.name == "Alice"
def test_user_email():
user = create_user("Alice")
assert user.email == "[email protected]"
def test_user_age():
user = create_user("Alice")
assert user.age == 30
# 悪い例: テスト内のハードコードされた値
def test_bad_mock():
with patch("mypackage.API.get") as mock_get:
mock_get.return_value = {"data": "test"}
result = call_api()
assert result == {"data": "test"}
# 良い例: 明確で再利用可能なモック値
@pytest.fixture
def api_response():
return {"data": "test"}
@pytest.fixture
def mock_api_call(api_response):
with patch("mypackage.API.get") as mock_get:
mock_get.return_value = api_response
yield mock_get
def test_good_mock(mock_api_call, api_response):
result = call_api()
assert result == api_response
トラブルシューティング
よくある問題と解決策
-
テストが遅すぎる
- 低速テストに
@pytest.mark.slowを使う - CIでは
-m "not slow"で低速テストをスキップ - 問題の原因を特定するには
pytest --durations=10を使う
- 低速テストに
-
モックが効かない
- パッチする正しいパスを使っていることを確認 (
mypackage.module.function) - ターゲットコードがインポートする前に関数/クラスをパッチしているか確認
- 必要に応じて
autouse=Trueを使う
- パッチする正しいパスを使っていることを確認 (
-
フィクスチャが共有されない
- スコープが適切に設定されているか確認 (
scope="session") - フィクスチャが
conftest.pyに正しく配置されているか確認 - フィクスチャの競合を避ける
- スコープが適切に設定されているか確認 (
-
非同期テストの問題
pytest-asyncioがインストールされているか確認- イベントループが適切に閉じられているか確認
- 設定に応じて
@pytest.mark.asyncioを使う