1つのPRに数十のバックエンドテスト:当社のFastAPIテスト戦略
プルリクエストの説明には「テストスイートを追加:認証、データ層、API全体を対象とする52件のテスト」とありました。マージされたのは、対象のFastAPIバックエンドが本番稼働してから3か月後です。本来のテストの進め方ではなく、称賛すべきことでもありません。ただし、正直に検討する価値はあります。「遅れてテストを追加する」ことと「一度も追加しない」ことは大きく異なり、その差は、保守できるコードベースと、いずれ書き直すことになるコードベースの差そのものだからです。
本記事では、当社が採用したテスト構成、実際のFastAPIプロジェクトで継続して使えた具体的なパターン、そしてこのPRがテスト件数以外に何を含んでいるかを説明します。
プロジェクトの背景
バックエンドは、EC事業者の顧客向け業務を担うFastAPIサービスです。受注管理、在庫照会、価格ルール、外部の物流APIとの連携を処理します。データベースはPostgreSQLで、非同期処理に対応したSQLAlchemy経由でアクセスします。認証はJWT方式で、別の認証サービスに対してトークンを検証します。通常の処理で呼び出す外部APIは、物流事業者、通貨換算サービス、商品データフィードの3つです。
本番稼働の3か月間には、バグ修正、機能追加、段階的な変更がありましたが、すべてテストなしでした。コードがテスト不能だったわけではありません。依存性を注入し、グローバルな状態を持たず、FastAPIの依存性注入でリクエストごとにデータベースセッションを作るなど、テストしやすさを考慮していました。単にテストが書かれていなかったのです。目的を絞ったPRが適切だったのは、テストを一括で追加する方法を勧めたいからではありません。「テストを含む1つのPR」ならレビューされ、マージされ、保守されるためです。「少しずつテストを追加する」は、いつまでもバックログに残りがちです。
フィクスチャの階層
pytestのフィクスチャは、意図的に設計すると効果を発揮します。実際のデータベースを使うFastAPIプロジェクトでは、セッション、モジュール、関数の3つのスコープが自然な階層になります。このテストスイートでは、主にセッションと関数のスコープを使います。
セッションスコープのフィクスチャは、テスト実行全体で1回だけ動きます。データベース作成、スキーマの作成(以下の例ではBase.metadata.create_allを使いますが、Alembicによるマイグレーションもこの段階で行います)、テスト間で変わらない参照データの投入が該当します。データベースエンジンをすべてのテストで共有するため、スキーマ作成のコストはテストファイルごとではなく、1回だけ発生します。
関数スコープのフィクスチャは、各テストに独立した状態を提供します。重要なのはトランザクションをロールバックするフィクスチャです。各テストの開始時にトランザクションを開き、終了時にロールバックします。ユーザー作成、注文挿入、価格更新など、テストによる書き込みは自動で取り消されます。テスト間でテーブルを空にせずに、テストを分離できます。
import pytest
import pytest_asyncio
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker
@pytest_asyncio.fixture(scope="session")
async def engine():
engine = create_async_engine(settings.TEST_DATABASE_URL, echo=False)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield engine
await engine.dispose()
@pytest_asyncio.fixture
async def db_session(engine):
"""Each test gets a transaction that rolls back on completion."""
async with engine.begin() as conn:
await conn.begin_nested()
session_factory = sessionmaker(
conn, class_=AsyncSession, expire_on_commit=False
)
async with session_factory() as session:
yield session
await session.rollback()
begin_nested()は、外側のトランザクション内にセーブポイントを作成します。意図しているのは、テストコードがセッション内で自由にコミットでき、終了処理で外側のトランザクションをロールバックするとすべて元に戻る仕組みです。ただし、示したフィクスチャでは、これを完全には実現できていません。engine.begin()はブロックが正常に終了するとコミットし、session.rollback()はセッション自体の処理しかロールバックしません。完全に分離するには、engine.connect()で接続を開き、conn.begin()で外側のトランザクションを開始し、join_transaction_mode="create_savepoint"を指定してセッションを接続にバインドしたうえで、テスト後に外側のトランザクションを明示的にロールバックします。この設定では、アプリケーションコード内のsession.commit()の呼び出しはセーブポイントを解放するだけなので、外側のロールバックでその変更も元に戻せます。
外部APIのモック化
外部APIが3つあれば、ネットワークにアクセスするテストには不安定さの原因も3つあります。標準的な対策であるHTTPクライアントのモック化は、トランスポート層でhttpxリクエストを捕捉するrespxを使えば簡単に実装できます。
import respx
import httpx
import pytest
@pytest.mark.asyncio
async def test_shipping_estimate_returns_carrier_data(test_client, valid_token):
mock_response = {
"carrier": "DHL",
"estimated_days": 3,
"cost_eur": 8.50
}
with respx.mock:
respx.post("https://api.logistics-provider.com/v2/rates").mock(
return_value=httpx.Response(200, json=mock_response)
)
response = await test_client.post(
"/api/v1/shipping/estimate",
json={"destination_zip": "10115", "weight_kg": 2.5},
headers={"Authorization": f"Bearer {valid_token}"}
)
assert response.status_code == 200
assert response.json()["estimated_days"] == 3
respx.mockコンテキストマネージャーは、外向きのhttpxリクエストをすべてモック層へ送ります。明示的に登録していないリクエストはエラーになり、これが適切な初期動作です。テストが誤って本番APIにアクセスする不具合は検出しにくく、副作用が発生すると大きな損失につながります。
EUR以外の通貨の注文ごとに呼び出す通貨換算サービスには、標準の為替レートを設定したrespxルーターを共有フィクスチャで用意します。通貨処理のテストでは特定の通貨ペアのレートを上書きし、別の処理をテストしていて通貨換算が呼ばれる場合は、フィクスチャの初期値をそのまま使います。この分離により、配送のテストは配送に集中でき、その時点で設定されている為替レートに左右されず、テストの目的が明確になります。
認証フローのテスト
FastAPIの依存性注入は、テストで特に役立つ機能です。このプロジェクトの認証は依存関係として実装されており、保護された各ルートはcurrent_user: User = Depends(get_current_user)を宣言します。get_current_userはJWTを検証し、ユーザーオブジェクトを返します。テストでは、アプリケーション全体でこの依存関係を差し替えます。
from app.main import app
from app.dependencies import get_current_user
from app.models import User
def make_user(role: str = "operator") -> User:
return User(id="test-user-id", email="[email protected]", role=role)
@pytest.fixture
def operator_client(db_session):
app.dependency_overrides[get_current_user] = lambda: make_user("operator")
client = TestClient(app)
yield client
app.dependency_overrides.clear()
@pytest.fixture
def admin_client(db_session):
app.dependency_overrides[get_current_user] = lambda: make_user("admin")
client = TestClient(app)
yield client
app.dependency_overrides.clear()
役割ごとに別のフィクスチャを用意すると、権限境界のテストが明確になります。オペレーターが管理者の操作を試みたときに403が返ることを確認するテストは、対象の業務ルールと同じくらい明瞭です。operator_client、admin_clientという名前なら、本文を読まずにテストの意図が分かります。
少数のテストでは、期限切れ、不正な形式、誤ったaudienceクレームを持つトークンなど、JWT検証のコード経路を直接実行します。差し替え用のフィクスチャは使わず、トークンをそのままテストクライアントへ渡し、401または403の応答を確認します。意図的な分離です。大半のテストは認証後の動作が対象なので差し替えを使い、認証テストでは認証ロジックそのものを確認します。混在させると、各テストが何を検証しているかが曖昧になります。
エラー経路を意識してテストする
正常系のテストは書きやすく、時間がないとそれだけになりがちです。しかし、本番で表面化するバグは正常系にはあまりありません。このコードベースで最も重大な未検証の動作は、エラー処理でした。物流APIが503を返したらどうなるか。必須フィールドがリクエストにないと何を返すか。同時更新によってデータベース制約に違反したらどうなるか、といった点です。
各外部APIのモックには、成功ケースに加えて少なくとも1つのエラーケースを含めます。物流APIのフィクスチャには503を返すパターンを用意し、例外が伝播して500になるのではなく、適切なHTTPステータスコードを伴う構造化されたエラー応答になることを確認します。データベース制約のテストでは競合する挿入を試し、未処理の整合性エラーではなく409を返すことを確認します。
こうした検証範囲は、後からテストを書くだけでは自然には得られません。設計時に障害の種類を意識して列挙する必要があります。これは、実装後ではなく実装と並行してテストを書くべき理由の1つです。実装の記憶が新しく、境界条件をまだ覚えているうちは、失敗するケースを列挙しやすいためです。
遅れて追加するテストの投資対効果
3か月遅れてでもテストする方が、一度もしないより良い理由は、理念ではなく実益にあります。このPRの作成中に既存のバグが3つ見つかりました。任意の住所フィールドのnullチェックがなく、請求先住所の2行目がない注文で500になる問題、結果が空の場合にだけ発生するページネーションの1件分のずれ、リソース作成エンドポイントのHTTPステータスコードの誤り(201ではなく200)です。どれも数か月間本番にありましたが、ユーザーから苦情がなかったため発見されませんでした。テストは半日で見つけました。
より長く残る効果は、動作を示す文書としての役割です。認証の境界、外部APIとのやり取り、エラー条件を網羅するテストスイートは、テストが通る限り正確さを保つAPIの仕様です。「オペレーターは管理者用エンドポイントにアクセスできない」というテストは、回帰を検出するだけでなく、次にコードを読む人に意図を伝えます。元の作成者以外が保守するサービスでは、そしていずれすべてのサービスがそうなりますが、その仕様の価値は作成時に見つけたバグを大きく上回ります。
遅れてテストを書くことへの反論は、コストが一度に発生するという点です。大きなPR、集中的なレビュー、機能開発に使えた時間が必要になります。これは事実です。しかし、適切な機会が来ないため未検証のまま残るコードベースには、以後のすべての変更に分散する、測定されにくい累積コストがかかります。遅れたテストスイートのコストは1回です。テストスイートがないコストは継続します。