テストが育てる堅牢API:FastAPI×pytest×TestClientで始める自動テスト完全ガイド
✅ 最初に要約(インバーテッドピラミッド)
- この記事でできること
FastAPIアプリに自動テストを導入し、単体テスト/統合テスト/依存差し替え(Dependency Override)/DBを使ったテストデータ準備/非同期エンドポイントの検証まで、現場で通用する一連の流れを構築できます。 - 扱う主なトピック
pytest
の基本設定とテスト作法fastapi.testclient.TestClient
による同期テストhttpx.AsyncClient
+pytest-asyncio
での非同期テスト- 依存性の差し替え(DB・認証)とテスト用SQLiteの使い分け
- フィクスチャで再現性の高いテストデータを管理
- カバレッジ測定と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_overrides
でDB依存をすげ替え、安全・再現性アップ。
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-asyncio
と httpx.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. 速度と安定性を両立するコツ
- 単体テストを厚く、統合テストは要所に
- 細かな振る舞いは関数単位のテストへ。
- HTTPエンドポイントの統合テストは要件の核心部分に集中。
- テストデータの最小化
- 必要十分なデータだけを作る。過剰なシードはテストを脆くします。
- 並列実行
- I/Oが重い場合は
pytest -n auto
(pytest-xdist
導入時)で並列化を検討。
- I/Oが重い場合は
- フレーク(たまに落ちる)対策
- 時刻・乱数・並行処理に決定性を与える(固定シード、時間のモック)。
- 命名と構造
- 「何を前提に」「どう操作すると」「どうなるか」をテスト名に。
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.anyio
とhttpx.AsyncClient
を基本に、DB層は専用フィクスチャで準備。- セッションライフサイクル(接続→トランザクション→ロールバック)をasync対応に揃える。
12.2 テストでの遅延I/O対策
- 外部API呼び出しはモック(関数差し替え/
unittest.mock
)。 - メール送信・通知はキュー投入の関数をテストし、実際の送信はモックに。
- バックグラウンドタスクはエントリ関数を直接呼び、副作用(ファイル書き込みなど)をモックする。
要点まとめ
- 非同期×外部I/Oはモックと分離が命。
- 「自前の境界」できれいに切るとテストが速く・堅くなります。
13. よくある落とし穴と回避策
症状 | 原因 | 対策 |
---|---|---|
ローカルでは通るがCIで失敗 | テストが外部環境に依存(時刻、ネットワーク、ファイル) | モック・固定シード・一時ディレクトリ使用。外部APIはスタブ化 |
テストが遅い | 大量データ投入・統合テスト過多 | 単体テスト比率を増やす。ファクトリで最小データに絞る |
たまに落ちる(フレーク) | 並行実行や非決定的要素 | 直列化・待機条件の明示・リトライよりも原因の排除を最優先 |
本番DBに書き込んだ | 依存を差し替えていない | dependency_overrides をテスト共通で適用。DB URLを必ず分離 |
失敗時の原因が追えない | アサーションが抽象的 | 期待値を具体的に(ステータス・本文・件数・順序)。失敗時の差分が見えるように |
要点まとめ
- テストの再現性を最優先。
- 問題は「モック不足」「データ過多」「非決定性」に収束しがち。
14. 仕上げ:小さく始めて、自然に育てる
- スモークテスト(起動・簡単な作成/取得)から。
- 失敗パス(404・400)を加え、仕様の輪郭を固める。
- トランザクションロールバックでクリーンなテストに。
- 依存差し替えで認証・外部I/Oをテストしやすく。
- 非同期やMockで本番に近いふるまいを段階的に再現。
- カバレッジで抜けを見つけ、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本のテストを差し込むこと。そこから、品質も自信も、きっと自然に育っていきますよ♡