tva
← コラム

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回です。テストスイートがないコストは継続します。

関連記事

関連記事