テスト — pytestによるエンドツーエンド品質保証
テストは安全ネットのようなものです。綱渡り (コードを書くこと)をしている間は見えませんが, 足を滑らせた (バグに遭遇した)とき, 唯一の命綱になります。安全ネットなしのパフォーマンスは, 遅かれ早かれ事故に終わる運命にあります。
1. 学ぶ内容
TestClient(httpx)の基礎:同期/非同期テストクライアント- テストデータベース分離:各テストで個別のデータベースを使用し, fixtureで管理
- 依存性オーバーライド:
app.dependency_overridesでDB/認証依存性を置き換え - テストピラミッド:ユニット → インテグレーション → E2Eテストの階層戦略
- Aliceシナリオ:PriceTrackerの完全なテストスイート
2. Aliceのリアルストーリー
(1) ペインポイント:リリース後にしかバグが見つからない
AliceがPriceTrackerをデプロイした後, JWTトークンの有効期限が1秒に設定されていたこと, バッチインポートがすべての検証をスキップしていたこと, Proユーザーのレート制限がFreeユーザーと同じだったことに気づきました。バグを修正するたびに新しいバグを生み出し, Bobは「先週動いていた機能が今週動かなくなった」と不満を言いました。Aliceには自動テストがなく, 完全にSwagger UIの手動操作に依存しており, 各回帰テストに2時間かかっていました。
(2) pytest自動テストのソリューション
pytest + httpx TestClientにより, すべてのAPIエンドポイントに自動テストが保証されます。dependency_overridesで本番データベースをテストデータベースに置き換え, git pushのたびにすべてのテストが自動実行され, 2分以内にすべての回帰問題が検出されます。
PYTHON
def test_create_product(client):
response = client.post("/api/v1/products", json={"name": "Widget", "price": 9.99})
assert response.status_code == 201
assert response.json()["name"] == "Widget"
(3) 収益
回帰テストが2時間の手動作業から2分の自動化に変わり, JWT有効期限のバグが開発フェーズのテストで発見されるようになり, デプロイ後のバグ数が週5件から月1件に減少しました。
3. TestClientの基礎
(1) テストピラミッド
graph TD
E2E[E2Eテスト - 少数] --> INT[インテグレーションテスト - 中程度]
INT --> UNIT[ユニットテスト - 多数]
UNIT --- U1[Pydanticモデル検証]
UNIT --- U2[Repository関数]
UNIT --- U3[サービスロジック]
INT --- I1[APIエンドポイント + DB]
INT --- I2[認証フロー]
INT --- I3[CRUD操作]
E2E --- E1[完全なユーザージャーニー]
E2E --- E2[WebSocket + API]
| レベル | 数量 | 速度 | 依存性 |
|---|---|---|---|
| ユニットテスト | 多い (100+) | 高速 (< 1 ms) | 外部依存なし |
| インテグレーションテスト | 中程度 (30-50) | 中程度 (10-100 ms) | テストデータベース |
| E2Eテスト | 少ない (5-10) | 低速 (1-5 s) | 完全な環境 |
(1) ▶サンプル:TestClient基本fixture
PYTHON
# tests/conftest.py
import pytest
from fastapi.testclient import TestClient
from app.main import app
from app.core.deps import get_db
# データベース依存性をオーバーライド
def get_test_db():
# テスト用にインメモリSQLiteを使用
engine = create_async_engine("sqlite+aiosqlite:///test.db")
# ... セッションセットアップ
yield session
# ... クリーンアップ
@pytest.fixture
def client():
app.dependency_overrides[get_db] = get_test_db
with TestClient(app) as c:
yield c
app.dependency_overrides.clear()
出力:
TEXT
# 関数定義成功
(2) ▶サンプル:基本エンドポイントテスト
PYTHON
# tests/test_health.py
def test_health_check(client):
response = client.get("/health")
assert response.status_code == 200
data = response.json()
assert data["status"] == "healthy"
assert "service" in data
def test_openapi_docs_available(client):
response = client.get("/docs")
assert response.status_code == 200
出力:
TEXT
# 関数定義成功
4. テストデータベース分離
(1) 各テストで個別のデータベース
(1) ▶サンプル:テストデータベースfixture
PYTHON
# tests/conftest.py
import pytest
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
from app.models import Base
TEST_DATABASE_URL = "sqlite+aiosqlite:///test_pricetracker.db"
@pytest.fixture(scope="function")
async def test_db():
# 各テスト用に新しいテストデータベースを作成
engine = create_async_engine(TEST_DATABASE_URL, echo=False)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
TestSession = async_sessionmaker(engine, expire_on_commit=False)
async with TestSession() as session:
yield session
# テスト後に全テーブルを削除
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
await engine.dispose()
出力:
TEXT
# 関数定義成功
(2) ▶サンプル:非同期テストクライアント
PYTHON
# tests/conftest.py
import pytest
from httpx import AsyncClient, ASGITransport
from app.main import app
@pytest.fixture
async def async_client():
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
yield ac
出力:
TEXT
# 関数定義成功
(2) 分離戦略の比較
| 戦略 | 速度 | 分離性 | 適用性 |
|---|---|---|---|
| 各テストでテーブルを新規作成 | 低速 | 完全に分離 | データ整合性テスト |
| トランザクションロールバック | 高速 | 良好 | ほとんどのインテグレーションテスト |
| インメモリSQLite | 高速 | 良好 | PostgreSQL機能に依存しないテスト |
5. 依存性オーバーライドのテクニック
(1) 認証とデータベースの置き換え
(1) ▶サンプル:認証依存性のオーバーライド
PYTHON
# tests/conftest.py
from app.core.deps import get_current_user, get_db
def get_test_user():
"""テスト用のモック認証ユーザー"""
return {"id": 1, "email": "alice@test.com", "role": "admin", "subscription": "pro"}
@pytest.fixture
def auth_client(client):
# 認証をオーバーライド - 実際のJWTは不要
app.dependency_overrides[get_current_user] = get_test_user
yield client
app.dependency_overrides.clear()
出力:
TEXT
# 関数定義成功
(2) ▶サンプル:保護されたエンドポイントのテスト
PYTHON
# tests/test_products.py
def test_list_products_unauthorized(client):
"""認証トークンなしでは401を返すべき"""
response = client.get("/api/v1/products")
assert response.status_code == 401
def test_list_products_authorized(auth_client):
"""モック認証付きでは200を返すべき"""
response = auth_client.get("/api/v1/products")
assert response.status_code == 200
def test_create_product(auth_client):
response = auth_client.post(
"/api/v1/products",
json={"name": "Widget", "category": "electronics", "base_price": 29.99},
)
assert response.status_code == 201
assert response.json()["name"] == "Widget"
出力:
TEXT
# 関数定義成功
(3) ▶サンプル:サブスクリプションレベル別権限テストのオーバーライド
PYTHON
def get_free_user():
return {"id": 2, "email": "free@test.com", "role": "user", "subscription": "free"}
def get_pro_user():
return {"id": 3, "email": "pro@test.com", "role": "user", "subscription": "pro"}
def test_bulk_import_free_user_limited(client):
"""Freeユーザーは最大1000件までインポート可能"""
app.dependency_overrides[get_current_user] = get_free_user
prices = [{"product_id": i, "price": 9.99} for i in range(1500)]
response = client.post("/api/v1/prices/bulk", json=prices)
assert response.status_code == 403
assert "limit" in response.json()["detail"].lower()
app.dependency_overrides.clear()
def test_bulk_import_pro_user(client):
"""Proユーザーは最大100000件までインポート可能"""
app.dependency_overrides[get_current_user] = get_pro_user
prices = [{"product_id": i, "price": 9.99} for i in range(5000)]
response = client.post("/api/v1/prices/bulk", json=prices)
assert response.status_code == 201
app.dependency_overrides.clear()
出力:
TEXT
# 関数定義成功
6. 完全なテストスイートの例
(1) ▶サンプル:Pydanticモデルのユニットテスト
PYTHON
# tests/test_models.py
import pytest
from pydantic import ValidationError
from app.schemas import ProductCreate, PriceCreate
def test_product_create_valid():
p = ProductCreate(name="Widget", category="electronics", base_price=29.99)
assert p.name == "Widget"
assert p.base_price == 29.99
def test_product_create_negative_price():
with pytest.raises(ValidationError) as exc:
ProductCreate(name="Widget", category="electronics", base_price=-1)
assert "greater than 0" in str(exc.value)
def test_product_create_empty_name():
with pytest.raises(ValidationError):
ProductCreate(name="", category="electronics", base_price=9.99)
def test_price_create_rounds_precision():
p = PriceCreate(product_id=1, price=9.999, currency="USD", source="test")
assert p.price == 10.0 # 小数点2桁に丸められる
def test_price_create_invalid_currency():
with pytest.raises(ValidationError):
PriceCreate(product_id=1, price=9.99, currency="XYZ", source="test")
出力:
TEXT
# 関数定義成功
(2) ▶サンプル:CRUD操作のインテグレーションテスト
PYTHON
# tests/test_crud.py
import pytest
from fastapi.testclient import TestClient
def test_product_crud_lifecycle(auth_client):
# 作成
create_resp = auth_client.post(
"/api/v1/products",
json={"name": "Test Widget", "category": "electronics", "base_price": 19.99},
)
assert create_resp.status_code == 201
product_id = create_resp.json()["id"]
# 読み取り
get_resp = auth_client.get(f"/api/v1/products/{product_id}")
assert get_resp.status_code == 200
assert get_resp.json()["name"] == "Test Widget"
# 更新
update_resp = auth_client.put(
f"/api/v1/products/{product_id}",
json={"name": "Updated Widget", "base_price": 24.99},
)
assert update_resp.status_code == 200
assert update_resp.json()["base_price"] == 24.99
# 削除
delete_resp = auth_client.delete(f"/api/v1/products/{product_id}")
assert delete_resp.status_code == 200
# 削除確認
get_resp2 = auth_client.get(f"/api/v1/products/{product_id}")
assert get_resp2.status_code == 404
出力:
TEXT
# 関数定義成功
❓ よくある質問
Q TestClientとAsyncClientはどちらを使うべきですか?
A シンプルなテストにはTestClient (同期)を使用し, 非同期ロジック (WebSocketなど)をテストする必要がある場合はAsyncClientを使用します。ほとんどのシナリオではTestClientで十分です。
Q テストデータベースにはSQLiteとPostgreSQLのどちらを使うべきですか?
A 開発ではSQLiteを使用 (高速), CIではPostgreSQLを使用 (本番環境と一致)。SQLiteは一部のPostgreSQL機能 (RETURNINGやJSONBなど)をサポートしていないことに注意してください。
Q fixtureのscopeはどう選ぶべきですか?
A
function (各テストで再作成)が最も安全, session (テストセッション全体で共有)が最も高速ですが分離性が低いです。データベースとTestClientにはfunctionを使用してください。Q WebSocketはどのようにテストしますか?
A
AsyncClient.websocket_connect()で接続を確立し, メッセージの送受信で動作を検証します。Q テストの実行が遅すぎる場合はどうすればよいですか?
A
pytest-xdistで並列実行 (pytest -n auto), テーブル作成の代わりにトランザクションロールバックを使用, インテグレーションテストの代わりにユニットテストを使用します。Q
dependency_overridesは他のテストに影響しますか?A はい, appのグローバルオブジェクトを変更するためです。fixtureの
yield文の後に必ずapp.dependency_overrides.clear()を配置してください。📖 まとめ
- テストピラミッド:ユニットテスト (頻繁に高速)→ インテグレーションテスト (中程度)→ E2E (少なく低速)
- TestClientはシンプルな同期エンドポイントのテストに, AsyncClientは非同期ロジック (WebSocketなど)のテストに使用します
- 各テストは個別のデータベースを使用し, fixtureが作成とクリーンアップを管理します
app.dependency_overridesで認証とデータベースの依存性を置き換え, 実際のJWTやPGは不要です- 包括的なテストカバレッジ:Pydantic検証 (ユニット), CRUDワークフロー (インテグレーション), アクセス制御 (インテグレーション)
📝 練習問題
- 基本問題 (難易度 ⭐):TestClientを使って
/healthエンドポイントのテストを書き, 200ステータスコードと正しいJSON構造が返されることを検証します。ヒント:TestClient(app)+client.get("/health") - 応用問題 (難易度 ⭐⭐):CRUDライフサイクルテストを書きます。プロダクトの作成, 照会, 更新, 削除を行い, 各ステップのステータスコードと戻りデータを検証します。
dependency_overridesで認証をスキップします。ヒント:app.dependency_overrides[get_current_user] = mock_fn - チャレンジ (難易度 ⭐⭐⭐):完全なテストスイートを実装します。Pydantic検証テスト (不正データで
ValidationErrorが発生), 認証テスト (トークンなしで401, Freeユーザーで403, Proユーザーで200), ページネーションテスト (skipとlimitの値が正しく返される)。ヒント:pytest.raises(ValidationError)+ 複数のdependency_overridesfixture
---|



