Pythonテストパターン
pytest、TDD手法、ベストプラクティスを使用したPythonアプリケーション向けの包括的なテスト戦略。
アクティブ化のタイミング
- 新しいPythonコードを書く場合(TDDに従う:Red、Green、Refactor)
- 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("error message")
# 例外メッセージの確認
with pytest.raises(ValueError, match="invalid input"):
raise ValueError("invalid input provided")
# 例外属性の確認
with pytest.raises(ValueError) as exc_info:
raise ValueError("error message")
assert str(exc_info.value) == "error message"
フィクスチャ
基本的なフィクスチャの使用法
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
セットアップ/ティアダウン付きフィクスチャ
@pytest.fixture
def database():
"""セットアップとティアダウンを伴うフィクスチャ。"""
# セットアップ
db = Database(":memory:")
db.create_tables()
db.insert_test_data()
yield db # テストに提供
# ティアダウン
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の誤用を検出するためのオートスペックを使用したテスト。"""
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):
"""このクラス内の各テストの前に実行されるセットアップ。"""
self.service = UserService()
def test_create_user(self):
"""ユーザー作成をテスト。"""
user = self.service.create_user("Alice")
assert user.name == "Alice"
def test_delete_user(self):
"""ユーザー削除をテスト。"""
user = self.service.create_user("Bob")
self.service.delete_user(user.id)
assert self.service.get_user(user.id) is None
環境設定
pytest.ini設定
[pytest]
minversion = 7.0
python_files = test_*.py
python_classes = Test*
python_functions = test_*
testpaths = tests
markers =
slow: 遅いテスト
integration: 統合テスト
unit: 単体テスト
smoke: スモークテスト
filterwarnings =
ignore::DeprecationWarning
addopts =
-v
-rs
--strict-markers
--strict-config
conftest.pyの推奨設定
# tests/conftest.py
import pytest
import os
# テスト環境変数を設定
os.environ["TESTING"] = "True"
os.environ["DATABASE_URL"] = "sqlite:///test.db"
@pytest.fixture(autouse=True)
def setup_test_env():
"""各テストの前にテスト環境を設定。"""
# データベースのクリーンアップなど
yield
# テスト後のクリーンアップ
@pytest.fixture(scope="session")
def db_connection():
"""テストセッション全体で使用されるデータベース接続。"""
from database import get_connection
conn = get_connection()
yield conn
conn.close()
テストの実行コマンド
# 全てのテストを実行
pytest
# 詳細出力で実行
pytest -v
# 特定のテストファイルを実行
pytest tests/unit/test_models.py
# 特定のテスト関数を実行
pytest tests/unit/test_models.py::test_user_creation
# 特定のテストクラスを実行
pytest tests/unit/test_models.py::TestUserModel
# カバレッジレポート付きで実行
pytest --cov=mypackage --cov-report=term-missing
# HTMLカバレッジレポートを生成
pytest --cov=mypackage --cov-report=html
# 失敗時に停止
pytest -x
# 最後の失敗を再実行
pytest --lf
# 失敗したテストを再試行
pytest --reruns 3
# 並列実行
pytest -n 4
# 特定のマーカーでフィルタリング
pytest -m "unit and not slow"
テスト戦略
ユニットテストのベストプラクティス
- 各テストは1つの動作を検証する
- 説明的なテスト名を使用する
- テストは決定論的であるべき(常に同じ結果)
- 外部依存関係をモック化する
- テストを小さく保つ
統合テストのベストプラクティス
- 実際の外部サービスを使用する(可能な場合)
- 完全なユーザーフローをテストする
- エラーシナリオを含める
- テストデータを適切にクリーンアップする
テスト駆動開発の原則
- コードを書く前にテストを書く
- サイクルを小さく保つ(数分)
- 失敗するテストから始める
- テストに合格するための最小限のコードだけを書く
- 絶えずリファクタリングする
テストパターン
AAAパターン(Arrange-Act-Assert)
def test_user_registration():
"""AAAパターンを使用:Arrange-Act-Assert。"""
# Arrange
service = UserService()
user_data = {"username": "alice", "email": "[email protected]"}
# Act
user = service.register_user(user_data)
# Assert
assert user.username == "alice"
assert user.email == "[email protected]"
assert user.is_active is True
データ駆動テスト
def load_test_data():
"""外部ソースからテストデータをロード。"""
import json
with open("tests/test_data.json") as f:
return json.load(f)
@pytest.mark.parametrize("input,expected", load_test_data())
def test_validation_rules(input, expected):
"""外部データソースを使用したデータ駆動テスト。"""
assert validate(input) == expected
依存性注入を使ったテスト
def test_with_dependency_injection():
"""依存性注入を使ったテスト。"""
class MockEmailService:
def send(self, to, subject, body):
self.sent = True
self.last_to = to
email_service = MockEmailService()
service = NotificationService(email_service)
service.send_welcome_email("[email protected]")
assert email_service.sent is True
assert email_service.last_to == "[email protected]"
開発環境セットアップ
Visual Studio Code設定
{
"python.testing.pytestEnabled": true,
"python.testing.unittestEnabled": false,
"python.testing.nosetestsEnabled": false,
"python.testing.pytestArgs": [
"-v",
"--cov=mypackage",
"--cov-report=html",
"tests"
],
"python.testing.cwd": "${workspaceFolder}"
}
PyCharm設定
- Settings/Preferences > Tools > Python Integrated Tools > Testing に移動
- デフォルトテストランナーとして pytest を選択
- Configuration で追加の引数を設定
GitHub Actions CI設定
name: Python Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.9", "3.10", "3.11"]
steps:
- uses: actions/checkout@v3
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -e .[dev]
- name: Run tests with coverage
run: |
pytest --cov=mypackage --cov-report=xml
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v3
テスト要件(requirements-dev.txt)
pytest>=7.0
pytest-cov>=4.0
pytest-mock>=3.0
pytest-asyncio>=0.21
pytest-xdist>=3.0
pytest-rerunfailures>=10.0
coverage>=7.0
pytestのヒントとコツ
テストフィルタリング
# 名前に"api"が含まれるテストを実行
pytest -k "api"
# 名前に"api"が含まれないテストを実行
pytest -k "not api"
# 名前に"user"または"auth"が含まれるテストを実行
pytest -k "user or auth"
# 名前に"test_get"が含まれ、"slow"マーカーが付いていないテストを実行
pytest -k "test_get and not slow"
デバッグのヘルプ
# 詳細度を上げる(最大は-vvv)
pytest -vv
# print文の出力を表示
pytest -s
# テスト実行の一時停止とデバッグ
pytest --pdb
# 最初の失敗で停止
pytest -x
# 遅いテストの表示
pytest --durations=10
pytestのpdb統合
import pytest
def test_with_debugging():
"""テストのデバッグにpdbを使用。"""
x = 10
y = 0
# テスト実行中にpdbで一時停止するには、--pdbを使用して実行
result = divide(x, y)
assert result == 5
テストの無効化
@pytest.mark.skip(reason="まだ実装されていません")
def test_not_yet_implemented():
"""条件付きでスキップされるテスト。"""
assert False
@pytest.mark.skipif(sys.version_info < (3, 10), reason="Python 3.10+ が必要")
def test_python_version_dependent():
"""特定のPythonバージョンでのみ実行されるテスト。"""
assert True
@pytest.mark.xfail(reason="既知のバグ")
def test_known_bug():
"""失敗すると予想されるテスト(既知のバグ)。"""
assert False
このパターンは、単体テスト、統合テスト、TDDワークフロー、ベストプラクティスをカバーするpytestテストを作成するための包括的なガイドです。