404 Not Found

404 Not Found


nginx

テスト — pytestによるエンドツーエンド品質保証

テストは安全ネットのようなものです。綱渡り (コードを書くこと)をしている間は見えませんが, 足を滑らせた (バグに遭遇した)とき, 唯一の命綱になります。安全ネットなしのパフォーマンスは, 遅かれ早かれ事故に終わる運命にあります。

1. 学ぶ内容


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) テストピラミッド

100%
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()を配置してください。

📖 まとめ


📝 練習問題

  1. 基本問題 (難易度 ⭐):TestClientを使って/healthエンドポイントのテストを書き, 200ステータスコードと正しいJSON構造が返されることを検証します。ヒント:TestClient(app) + client.get("/health")
  2. 応用問題 (難易度 ⭐⭐):CRUDライフサイクルテストを書きます。プロダクトの作成, 照会, 更新, 削除を行い, 各ステップのステータスコードと戻りデータを検証します。dependency_overridesで認証をスキップします。ヒント:app.dependency_overrides[get_current_user] = mock_fn
  3. チャレンジ (難易度 ⭐⭐⭐):完全なテストスイートを実装します。Pydantic検証テスト (不正データでValidationErrorが発生), 認証テスト (トークンなしで401, Freeユーザーで403, Proユーザーで200), ページネーションテスト (skiplimitの値が正しく返される)。ヒント:pytest.raises(ValidationError) + 複数のdependency_overrides fixture

---|

Web-Tutorial.com

Web-Tutorial 技術チーム

複数の開発者によって共同維持されているプログラミングチュートリアルプラットフォーム。各チュートリアルは専門分野の開発者が執筆・レビューしています。正確で信頼性の高いコンテンツを目指しています — 問題を見つけた場合はお知らせください。

100%