green snake
Photo by Pixabay on Pexels.com
目次

テストが育てる堅牢API:FastAPI×pytest×TestClientで始める自動テスト完全ガイド


✅ 最初に要約(インバーテッドピラミッド)

  • この記事でできること
    FastAPIアプリに自動テストを導入し、単体テスト/統合テスト/依存差し替え(Dependency Override)/DBを使ったテストデータ準備/非同期エンドポイントの検証まで、現場で通用する一連の流れを構築できます。
  • 扱う主なトピック
    1. pytest の基本設定とテスト作法
    2. fastapi.testclient.TestClient による同期テスト
    3. httpx.AsyncClientpytest-asyncio での非同期テスト
    4. 依存性の差し替え(DB・認証)とテスト用SQLiteの使い分け
    5. フィクスチャで再現性の高いテストデータを管理
    6. カバレッジ測定とCIに向けた運用のコツ
  • 得られる効果
    • 変更を怖がらない開発サイクル(リグレッション防止)
    • 仕様がテストで記述され、ドキュメントとしても活用可能
    • バグ報告に素早く再現テストを追加 → 修正 → 回帰確認の黄金ループ

🎯 だれに刺さる?(対象読者を具体化)

  • 個人開発者Aさん(大学3年)
    手元のFastAPIアプリが成長し、ふと気づくと何を直すとどこが壊れるか不安。最小コストでテスト基盤を整え、学びながら品質も上げたい方。
  • 小規模チームBさん(受託3名)
    仕様変更が多く、口頭合意のまま進んで実装と期待値がズレがち。テストで合意をコード化し、レビューの指標を増やしたい方。
  • SaaS開発Cさん(スタートアップ)
    スプリントごとに機能追加。本番障害が怖いので、PRごとにテストを自動実行し、デプロイの信頼性を高めたい方。

♿ アクセシビリティ評価と配慮

  • 読みやすさ:短文ベース・箇条書き多用・漢字/ひらがな/カタカナのバランスを調整。
  • 構造化:章ごとに「要点まとめ」を設置し、スクリーンリーダーでも段取りを把握しやすい構成。
  • コードの工夫:幅固定ブロック・丁寧なコメント・長行の回避・識別しやすい変数名。
  • 対象配慮:テスト未経験者にやさしく、経験者には実務上の工夫(DB差し替え、非同期、カバレッジ)まで踏み込み。
  • 総合レベルAA相当の分かりやすさを目指し、重要語は初出で簡潔に定義しました。

1. 準備:最小アプリとテスト用ディレクトリ

1.1 まずはサンプルアプリ(Todo)を用意

以降のテスト例は、最小CRUDのTodo APIを題材にします。既存プロジェクトでも構成だけ真似すればOKです。

fastapi-testing/
├─ app/
│  ├─ main.py
│  ├─ db.py
│  ├─ models.py
│  └─ schemas.py
└─ tests/
   ├─ conftest.py
   └─ test_todos.py

1.2 依存ライブラリ

python3 -m venv .venv && source .venv/bin/activate
pip install fastapi uvicorn sqlalchemy pydantic pytest
# 非同期テストやHTTPクライアントを使う場合(推奨)
pip install httpx pytest-asyncio
# カバレッジを取りたい場合(推奨)
pip install pytest-cov

用語メモ

  • pytest:Pythonデファクトのテストランナー。test_*.py を自動検出。
  • TestClient:FastAPI付属のシンクロナスクライアント。高速・書きやすい。
  • httpx.AsyncClient:非同期のHTTPクライアント。asyncエンドポイントの実運用に近い検証が可能。

2. アプリ本体(最小構成)

2.1 app/db.py(同期セッション)

# app/db.py
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker

DATABASE_URL = "sqlite:///./app.db"  # 本番相当は環境変数で切り替え推奨

engine = create_engine(
    DATABASE_URL,
    connect_args={"check_same_thread": False} if DATABASE_URL.startswith("sqlite") else {}
)
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False)

2.2 app/models.py(SQLAlchemy 2.x的な記述)

# app/models.py
from datetime import datetime
from sqlalchemy import String, DateTime, func, MetaData
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column

# 命名規約を固定すると後々のマイグレーションや差分が安定
naming_convention = {
    "ix": "ix_%(column_0_label)s",
    "uq": "uq_%(table_name)s_%(column_0_name)s",
    "ck": "ck_%(table_name)s_%(constraint_name)s",
    "fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
    "pk": "pk_%(table_name)s"
}

class Base(DeclarativeBase):
    metadata = MetaData(naming_convention=naming_convention)

class Todo(Base):
    __tablename__ = "todos"
    id: Mapped[int] = mapped_column(primary_key=True)
    title: Mapped[str] = mapped_column(String(200), nullable=False, index=True)
    is_done: Mapped[bool] = mapped_column(default=False, nullable=False)
    created_at: Mapped[datetime] = mapped_column(
        DateTime(timezone=True), server_default=func.now(), nullable=False
    )

2.3 app/schemas.py(Pydantic)

# app/schemas.py
from datetime import datetime
from pydantic import BaseModel

class TodoBase(BaseModel):
    title: str
    is_done: bool = False

class TodoCreate(TodoBase):
    pass

class Todo(TodoBase):
    id: int
    created_at: datetime
    class Config:
        # Pydantic v2では from_attributes=True、v1では orm_mode=True で同等
        from_attributes = True

2.4 app/main.py(FastAPI本体)

# app/main.py
from fastapi import FastAPI, Depends, HTTPException
from sqlalchemy.orm import Session
from app.db import SessionLocal, engine
from app.models import Base, Todo
from app.schemas import Todo, TodoCreate
from typing import List

# 本番ではAlembicを推奨。検証のためcreate_allを使用(簡易)
Base.metadata.create_all(bind=engine)

app = FastAPI(title="Todos API")

def get_db():
    db = SessionLocal()
    try:
        yield db
    finally:
        db.close()

@app.post("/todos", response_model=Todo, status_code=201)
def create_todo(payload: TodoCreate, db: Session = Depends(get_db)):
    todo = Todo(**payload.dict())
    db.add(todo)
    db.commit()
    db.refresh(todo)
    return todo

@app.get("/todos", response_model=List[Todo])
def list_todos(db: Session = Depends(get_db)):
    return db.query(Todo).order_by(Todo.id.asc()).all()

@app.get("/todos/{todo_id}", response_model=Todo)
def get_todo(todo_id: int, db: Session = Depends(get_db)):
    todo = db.get(Todo, todo_id)
    if not todo:
        raise HTTPException(404, "Todo not found")
    return todo

@app.put("/todos/{todo_id}", response_model=Todo)
def update_todo(todo_id: int, payload: TodoCreate, db: Session = Depends(get_db)):
    todo = db.get(Todo, todo_id)
    if not todo:
        raise HTTPException(404, "Todo not found")
    for k, v in payload.dict().items():
        setattr(todo, k, v)
    db.commit()
    db.refresh(todo)
    return todo

@app.delete("/todos/{todo_id}", status_code=204)
def delete_todo(todo_id: int, db: Session = Depends(get_db)):
    todo = db.get(Todo, todo_id)
    if not todo:
        raise HTTPException(404, "Todo not found")
    db.delete(todo)
    db.commit()
    return None

要点まとめ

  • サンプルは同期で統一し書きやすく。非同期パターンは後半で紹介。
  • テストでは依存差し替えにより本番DBを触らず、テスト専用DBへ誘導します。

3. pytestの基本セットアップと作法

3.1 ディレクトリと命名規則

  • tests/ 配下に test_*.py を置くと自動検出。
  • テスト関数名は test_~、クラスは Test~ が慣例。

3.2 はじめてのテスト

# tests/test_smoke.py
def test_math():
    assert 1 + 1 == 2
pytest -q

最小の“赤→緑”を体験しておくと、テストを書く手の迷いが減ります♡

要点まとめ

  • pytestは「ファイル名・関数名で自動検出」が基本。
  • まずは“動く”成功体験を。

4. TestClientでAPIを同期テストする

4.1 もっとも手軽な統合テスト

# tests/test_todos.py
from fastapi.testclient import TestClient
from app.main import app

client = TestClient(app)

def test_create_and_get_todo():
    # 作成
    resp = client.post("/todos", json={"title": "書類を出す"})
    assert resp.status_code == 201
    data = resp.json()
    assert data["title"] == "書類を出す"
    todo_id = data["id"]

    # 単体取得
    resp = client.get(f"/todos/{todo_id}")
    assert resp.status_code == 200
    got = resp.json()
    assert got["id"] == todo_id
    assert got["is_done"] is False

ポイント

  • TestClient はWSGI/ASGIアプリを同期的に叩けるシンプルな手段。
  • ただし本番のHTTP経路すべてを通るわけではないので、「まず動く」を確かめる用途にぴったり。

要点まとめ

  • TestClient書きやすさ優先
  • 迅速なフィードバックが必要な日々の回帰テストに向く。

5. テストDBと依存差し替え(Dependency Override)

本番(開発)DBを汚さずにテストを実行するには、get_db 依存をテスト専用のDBセッションに差し替えます。

5.1 conftest.pyで共通フィクスチャを定義

# tests/conftest.py
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from app.models import Base
from app.main import app, get_db

@pytest.fixture(scope="session")
def test_engine():
    # テスト専用SQLite(ファイル or メモリ)。ここではメモリにします。
    engine = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False})
    Base.metadata.create_all(engine)
    yield engine
    # scope="session"なので明示クリーンアップは省略可(プロセス終了時に解放)

@pytest.fixture
def db_session(test_engine):
    TestingSessionLocal = sessionmaker(bind=test_engine, autoflush=False, autocommit=False)
    session = TestingSessionLocal()
    try:
        yield session
    finally:
        session.close()

@pytest.fixture(autouse=True)
def override_dependency(db_session):
    # app側の依存をテスト用セッションに差し替え
    def _get_db_for_test():
        try:
            yield db_session
        finally:
            pass
    app.dependency_overrides[get_db] = _get_db_for_test
    yield
    app.dependency_overrides.clear()

5.2 これですべてのテストがテストDBで動く

# tests/test_todos.py(再掲・DBは自動的にテスト用へ切替)
from fastapi.testclient import TestClient
from app.main import app

client = TestClient(app)

def test_list_empty():
    resp = client.get("/todos")
    assert resp.status_code == 200
    assert resp.json() == []

要点まとめ

  • conftest.py のフィクスチャで共通準備を一元化。
  • app.dependency_overridesDB依存をすげ替え、安全・再現性アップ。

6. データセットの用意と後片付け(シード&トランザクション戦略)

6.1 テストデータ投入用のヘルパ

# tests/factories.py(任意・簡易ファクトリ)
from app.models import Todo

def make_todo(session, title="タスク", is_done=False):
    todo = Todo(title=title, is_done=is_done)
    session.add(todo)
    session.commit()
    session.refresh(todo)
    return todo

6.2 テストごとのロールバックでクリーン維持

テストケースごとにトランザクションを開始→終了時にロールバックすれば、毎回まっさらです。

# tests/conftest.py(追加)
@pytest.fixture
def transactional_session(test_engine):
    connection = test_engine.connect()
    trans = connection.begin()
    TestingSessionLocal = sessionmaker(bind=connection, autoflush=False, autocommit=False)
    session = TestingSessionLocal()
    try:
        yield session
    finally:
        session.close()
        trans.rollback()
        connection.close()

db_session の代わりに transactional_session を使うテストでは自動で巻き戻るので、後片付けが不要になります。

6.3 利用例

# tests/test_todos_data.py
from fastapi.testclient import TestClient
from app.main import app
from tests.factories import make_todo

client = TestClient(app)

def test_list_with_seed(transactional_session):
    make_todo(transactional_session, title="A")
    make_todo(transactional_session, title="B", is_done=True)

    resp = client.get("/todos")
    assert resp.status_code == 200
    titles = [t["title"] for t in resp.json()]
    assert titles == ["A", "B"]

要点まとめ

  • データはファクトリ関数/フィクスチャに寄せて重複排除
  • トランザクションのロールバックで毎回クリーン。

7. 失敗系・境界値・更新系のテスト

7.1 404や400などのエラーパス

def test_get_not_found():
    resp = client.get("/todos/9999")
    assert resp.status_code == 404
    assert resp.json()["detail"] == "Todo not found"

7.2 更新・削除の確認

def test_update_and_delete(transactional_session):
    from tests.factories import make_todo
    todo = make_todo(transactional_session, title="初期")

    # 更新
    resp = client.put(f"/todos/{todo.id}", json={"title": "更新後", "is_done": True})
    assert resp.status_code == 200
    assert resp.json()["title"] == "更新後"
    assert resp.json()["is_done"] is True

    # 削除
    resp = client.delete(f"/todos/{todo.id}")
    assert resp.status_code == 204

    # 消えたことを確認
    resp = client.get(f"/todos/{todo.id}")
    assert resp.status_code == 404

7.3 境界値の一例(タイトルの長さ)

モデル側で String(200) としているので、201文字は弾かれるのが自然です(SQLiteでは格納できても業務ルールとして検証するのが安全)。
Pydanticスキーマで constr(max_length=200) を使うなど、入力層で制御しておくとテストも書きやすくなります。

要点まとめ

  • 失敗パスは仕様の裏側。漏れをテストで可視化。
  • 境界値テストは落ちにくいバグを先回りで捕まえます。

8. 非同期エンドポイントをhttpxでテストする

8.1 pytest-asynciohttpx.AsyncClient

非同期実装のエンドポイント(async def)は、httpx.AsyncClient本番に近いI/Oを伴って検証できます。

# tests/test_async.py
import pytest
from httpx import AsyncClient
from app.main import app

pytestmark = pytest.mark.anyio  # テスト関数を非同期化

async def test_list_async():
    async with AsyncClient(app=app, base_url="http://test") as ac:
        resp = await ac.get("/todos")
        assert resp.status_code == 200
        assert isinstance(resp.json(), list)

コツ

  • pytest.mark.anyio(または pytest.mark.asyncio)で非同期テスト関数を許可
  • AsyncClient(app=app, base_url=...) でアプリをASGIとして直接叩きます。

要点まとめ

  • 非同期はhttpxでスマートに。
  • 実行パスが本番に近く、I/Oの挙動を再現しやすい。

9. 認証や権限をテストする(依存オーバーライドの応用)

9.1 認証依存を差し替える

たとえばアプリに以下の依存があるとします(擬似コード)。

# app/auth.py(例)
from fastapi import Depends, HTTPException

def get_current_user(token: str = "..."):
    # 本物はJWT検証などを実施
    raise NotImplementedError

def require_admin(user = Depends(get_current_user)):
    if getattr(user, "role", "") != "admin":
        raise HTTPException(403, "権限がありません")

テストではこの依存を擬似ユーザーで置き換えます。

# tests/conftest.py(追加例)
from types import SimpleNamespace
from app import auth
from app.main import app

@pytest.fixture
def as_admin():
    def fake_current_user():
        return SimpleNamespace(username="alice", role="admin")
    app.dependency_overrides[auth.get_current_user] = fake_current_user
    yield
    app.dependency_overrides.pop(auth.get_current_user, None)

9.2 管理者専用APIのテスト

# tests/test_admin.py
from fastapi.testclient import TestClient
from app.main import app

client = TestClient(app)

def test_admin_only(as_admin):
    # as_admin フィクスチャにより、管理者として実行
    resp = client.get("/admin/metrics")
    assert resp.status_code == 200

要点まとめ

  • 認証・権限は依存の差し替えで軽快にテスト。
  • JWTの生成・署名まで毎回やらずに振る舞いへ集中。

10. 速度と安定性を両立するコツ

  1. 単体テストを厚く、統合テストは要所に
    • 細かな振る舞いは関数単位のテストへ。
    • HTTPエンドポイントの統合テストは要件の核心部分に集中。
  2. テストデータの最小化
    • 必要十分なデータだけを作る。過剰なシードはテストを脆くします。
  3. 並列実行
    • I/Oが重い場合は pytest -n autopytest-xdist導入時)で並列化を検討。
  4. フレーク(たまに落ちる)対策
    • 時刻・乱数・並行処理に決定性を与える(固定シード、時間のモック)。
  5. 命名と構造
    • 「何を前提に」「どう操作すると」「どうなるか」をテスト名に。
    • Arrange-Act-Assert(準備→実行→検証)で読みやすく

要点まとめ

  • 速いテストよく走る。よく走れば品質が上がる
  • 安定性はデータ最小化決定性の確保から。

11. カバレッジ測定と実務の回し方

11.1 カバレッジ

pytest --cov=app --cov-report=term-missing -q
  • 何が未テストかが一目でわかり、優先順位が立てやすくなります。
  • 目標値は一律ではなく、最重要パスから底上げするのが現実的。

11.2 実務フロー(PR駆動)

  • 新機能:テストを先に(あるいは同時に)書き、期待する振る舞いをコード化
  • バグ修正:再現ケースのテストを追加 → 赤 → 修正 → 緑 → 回帰防止。
  • CI:PRごとに pytest を実行。alembic upgrade など事前準備もパイプラインで自動化。

要点まとめ

  • カバレッジは地図。足りない場所が見えます。
  • PRで常に走るテストが、品質の底上げに直結。

12. 非同期+DBの実戦パターン(発展編)

12.1 async ORM/ドライバを使う場合

  • pytest.mark.anyiohttpx.AsyncClient を基本に、DB層は専用フィクスチャで準備。
  • セッションライフサイクル(接続→トランザクション→ロールバック)をasync対応に揃える。

12.2 テストでの遅延I/O対策

  • 外部API呼び出しはモック(関数差し替え/unittest.mock)。
  • メール送信・通知はキュー投入の関数をテストし、実際の送信はモックに。
  • バックグラウンドタスクはエントリ関数を直接呼び、副作用(ファイル書き込みなど)をモックする。

要点まとめ

  • 非同期×外部I/Oはモックと分離が命。
  • 「自前の境界」できれいに切るとテストが速く・堅くなります。

13. よくある落とし穴と回避策

症状 原因 対策
ローカルでは通るがCIで失敗 テストが外部環境に依存(時刻、ネットワーク、ファイル) モック・固定シード・一時ディレクトリ使用。外部APIはスタブ化
テストが遅い 大量データ投入・統合テスト過多 単体テスト比率を増やす。ファクトリで最小データに絞る
たまに落ちる(フレーク) 並行実行や非決定的要素 直列化・待機条件の明示・リトライよりも原因の排除を最優先
本番DBに書き込んだ 依存を差し替えていない dependency_overrides をテスト共通で適用。DB URLを必ず分離
失敗時の原因が追えない アサーションが抽象的 期待値を具体的に(ステータス・本文・件数・順序)。失敗時の差分が見えるように

要点まとめ

  • テストの再現性を最優先。
  • 問題は「モック不足」「データ過多」「非決定性」に収束しがち。

14. 仕上げ:小さく始めて、自然に育てる

  1. スモークテスト(起動・簡単な作成/取得)から。
  2. 失敗パス(404・400)を加え、仕様の輪郭を固める。
  3. トランザクションロールバッククリーンなテストに。
  4. 依存差し替え認証・外部I/Oをテストしやすく。
  5. 非同期Mockで本番に近いふるまいを段階的に再現。
  6. カバレッジで抜けを見つけ、PRで自動実行する習慣へ。

ここまで来れば、テストは“作業”ではなく資産です。機能追加のたびに安心が増えていきますよ♡


付録A:テスト実行のコマンド集

# すべてのテストを実行(静かめな出力)
pytest -q

# 指定ファイル・指定テストだけ
pytest tests/test_todos.py::test_create_and_get_todo -q

# 失敗1件で停止
pytest -x

# カバレッジ
pytest --cov=app --cov-report=term-missing -q

# 非同期テストを混在させてもOK(anyio/asyncioマーカー使用)
pytest -q

付録B:サンプル・テスト戦略テンプレ

  • ユニット:バリデーション・ユーティリティ関数・クエリ生成ロジック
  • API統合:代表的な正常系・失敗系・境界値
  • 外部連携:モックでスタブ化、タイムアウト・再試行の挙動
  • セキュリティ:未認証・権限なし・ロール別ルート
  • パフォーマンス(任意):軽いマイクロベンチを導入し、リグレッション検知

まとめ(今日から“壊さずに進化”できる私へ)

  • pytest+TestClient最短距離の安心を、httpx非同期も実戦的に。
  • 依存差し替えテスト用SQLiteで、本番DBを汚さずに再現性の高い検証が可能。
  • トランザクションロールバックファクトリでデータ準備を安定化し、落ちないテストへ。
  • カバレッジとCIで“いつでもテストが走る”環境を整えれば、デプロイはもっと気楽になります。

わたしも最初はテストが難しく感じましたが、小さく始めるだけで、毎日の開発体験がぐっと楽になります。
次の一歩は、あなたのプロジェクトに最初の1本のテストを差し込むこと。そこから、品質も自信も、きっと自然に育っていきますよ♡

投稿者 greeden

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

日本語が含まれない投稿は無視されますのでご注意ください。(スパム対策)