サイトアイコン IT & ライフハックブログ|学びと実践のためのアイデア集

FastAPIの自動テスト入門:pytestとTestClient、テストDBを一貫して動かす

green snake

Photo by Pixabay on Pexels.com

FastAPIの自動テストは、pytestでテストを集め、TestClientでAPIへリクエストを送り、依存関係をテスト用データベースへ差し替えるところから始めます。

この3点を同じフィクスチャで結ぶと、本番データへ触れず、テストごとに同じ条件を再現できます。

この記事ではTodo ​APIを題材に、最小構成から正常系、失敗系、非同期処理、カバレッジまでを順番に組み立てます。

この記事で作るテスト基盤

最初に作るべきものは、機能を網羅する大量のテストではありません。

APIを起動し、データを1件作成し、取得結果を確かめられる短い経路です。

テストの種類と役割

テストの種類を分けると、失敗したときに調べる範囲が狭くなります。

種類 確かめる対象 例
単体テスト 関数やクラスの小さな振る舞い タイトル検証、料金計算、変換処理
APIテスト HTTP入力とレスポンス ステータスコード、JSON、エラー内容
統合テスト API、DB、依存関係のつながり 作成したTodoが一覧に現れること
E2Eテスト 実際のネットワークや外部サービスを含む全体 配置済み環境へリクエストを送ること

TestClientでアプリを直接呼ぶテストは、Webサーバーやリバースプロキシを経由しません。

したがって、アプリ内部のAPIテストには向きますが、配置後のネットワーク構成まで保証するものではありません。

準備するファイルとライブラリ

サンプルはPython ​3.10以降、FastAPI、Pydantic ​v2、SQLAlchemy ​2系の書式で統一します。

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

仮想環境を有効にしたうえで、次の依存ライブラリを入れます。

python ​-m ​pip ​install ​fastapi ​uvicorn ​sqlalchemy ​pydantic ​pytest ​httpx ​anyio ​pytest-cov

既存プロジェクトでは、先にバージョン固定ファイルと互換性を確認してください。

Todo ​APIの最小実装

テストを理解するために必要な範囲だけ、同期型のTodo ​APIを用意します。

入力検証を詳しく確認したい場合は、FastAPIの型ヒントとPydanticによる入力検証も参照できます。

データベース接続

# ​app/db.py
from ​collections.abc ​import ​Generator

from ​sqlalchemy ​import ​create_engine
from ​sqlalchemy.orm ​import ​Session, ​sessionmaker

DATABASE_URL ​= ​'sqlite:///./app.db'

engine ​= ​create_engine(
 ​ ​ ​ ​DATABASE_URL,
 ​ ​ ​ ​connect_args={'check_same_thread': ​False},
)
SessionLocal ​= ​sessionmaker(
 ​ ​ ​ ​bind=engine,
 ​ ​ ​ ​autoflush=False,
 ​ ​ ​ ​expire_on_commit=False,
)

def ​get_db() ​-> ​Generator[Session, ​None, ​None]:
 ​ ​ ​ ​with ​SessionLocal() ​as ​session:
 ​ ​ ​ ​ ​ ​ ​ ​yield ​session

テーブルモデル

# ​app/models.py
from ​datetime ​import ​datetime

from ​sqlalchemy ​import ​Boolean, ​DateTime, ​String, ​func
from ​sqlalchemy.orm ​import ​DeclarativeBase, ​Mapped, ​mapped_column

class ​Base(DeclarativeBase):
 ​ ​ ​ ​pass

class ​Todo(Base):
 ​ ​ ​ ​__tablename__ ​= ​'todos'

 ​ ​ ​ ​id: ​Mapped[int] ​= ​mapped_column(primary_key=True)
 ​ ​ ​ ​title: ​Mapped[str] ​= ​mapped_column(String(200), ​nullable=False)
 ​ ​ ​ ​is_done: ​Mapped[bool] ​= ​mapped_column(
 ​ ​ ​ ​ ​ ​ ​ ​Boolean, ​default=False, ​nullable=False
 ​ ​ ​ ​)
 ​ ​ ​ ​created_at: ​Mapped[datetime] ​= ​mapped_column(
 ​ ​ ​ ​ ​ ​ ​ ​DateTime(timezone=True),
 ​ ​ ​ ​ ​ ​ ​ ​server_default=func.now(),
 ​ ​ ​ ​ ​ ​ ​ ​nullable=False,
 ​ ​ ​ ​)

入出力スキーマ

# ​app/schemas.py
from ​datetime ​import ​datetime

from ​pydantic ​import ​BaseModel, ​ConfigDict, ​Field

class ​TodoCreate(BaseModel):
 ​ ​ ​ ​title: ​str ​= ​Field(min_length=1, ​max_length=200)
 ​ ​ ​ ​is_done: ​bool ​= ​False

class ​TodoRead(TodoCreate):
 ​ ​ ​ ​model_config ​= ​ConfigDict(from_attributes=True)

 ​ ​ ​ ​id: ​int
 ​ ​ ​ ​created_at: ​datetime

String(200)はDB列の定義です。

「タイトルは200文字以内」という入力ルールは、Field(max_length=200)にも明示します。

これにより、長すぎる入力をDBへ渡す前に検証できます。

APIエンドポイント

# ​app/main.py
from ​typing ​import ​Annotated

from ​fastapi ​import ​Depends, ​FastAPI, ​HTTPException, ​Response, ​status
from ​sqlalchemy ​import ​select
from ​sqlalchemy.orm ​import ​Session

from ​app.db ​import ​get_db
from ​app.models ​import ​Todo ​as ​TodoModel
from ​app.schemas ​import ​TodoCreate, ​TodoRead

app ​= ​FastAPI(title='Todos ​API')
DbSession ​= ​Annotated[Session, ​Depends(get_db)]
@app.post('/todos', ​response_model=TodoRead, ​status_code=201)
def ​create_todo(payload: ​TodoCreate, ​db: ​DbSession):
 ​ ​ ​ ​todo ​= ​TodoModel(**payload.model_dump())
 ​ ​ ​ ​db.add(todo)
 ​ ​ ​ ​db.commit()
 ​ ​ ​ ​db.refresh(todo)
 ​ ​ ​ ​return ​todo

@app.get('/todos', ​response_model=list[TodoRead])
def ​list_todos(db: ​DbSession):
 ​ ​ ​ ​statement ​= ​select(TodoModel).order_by(TodoModel.id)
 ​ ​ ​ ​return ​db.scalars(statement).all()

@app.get('/todos/{todo_id}', ​response_model=TodoRead)
def ​get_todo(todo_id: ​int, ​db: ​DbSession):
 ​ ​ ​ ​todo ​= ​db.get(TodoModel, ​todo_id)
 ​ ​ ​ ​if ​todo ​is ​None:
 ​ ​ ​ ​ ​ ​ ​ ​raise ​HTTPException(status_code=404, ​detail='Todo ​not ​found')
 ​ ​ ​ ​return ​todo
@app.put('/todos/{todo_id}', ​response_model=TodoRead)
def ​update_todo(todo_id: ​int, ​payload: ​TodoCreate, ​db: ​DbSession):
 ​ ​ ​ ​todo ​= ​db.get(TodoModel, ​todo_id)
 ​ ​ ​ ​if ​todo ​is ​None:
 ​ ​ ​ ​ ​ ​ ​ ​raise ​HTTPException(status_code=404, ​detail='Todo ​not ​found')
 ​ ​ ​ ​for ​name, ​value ​in ​payload.model_dump().items():
 ​ ​ ​ ​ ​ ​ ​ ​setattr(todo, ​name, ​value)
 ​ ​ ​ ​db.commit()
 ​ ​ ​ ​db.refresh(todo)
 ​ ​ ​ ​return ​todo

@app.delete('/todos/{todo_id}', ​status_code=204)
def ​delete_todo(todo_id: ​int, ​db: ​DbSession) ​-> ​Response:
 ​ ​ ​ ​todo ​= ​db.get(TodoModel, ​todo_id)
 ​ ​ ​ ​if ​todo ​is ​None:
 ​ ​ ​ ​ ​ ​ ​ ​raise ​HTTPException(status_code=404, ​detail='Todo ​not ​found')
 ​ ​ ​ ​db.delete(todo)
 ​ ​ ​ ​db.commit()
 ​ ​ ​ ​return ​Response(status_code=status.HTTP_204_NO_CONTENT)

この例では、アプリ起動時のテーブル作成処理を省いています。

ローカルでアプリを動かす前にマイグレーションを適用し、テストでは後述のフィクスチャで専用テーブルを作成します。

テストDBとTestClientを同じフィクスチャで結ぶ

メモリ上のSQLiteは、接続が変わると別のデータベースとして扱われる場合があります。

そこで、この直列実行向けサンプルではStaticPoolを使い、1テスト内のセッションとTestClientが同じ接続を共有するようにします。

# ​tests/conftest.py
from ​collections.abc ​import ​Iterator

import ​pytest
from ​fastapi.testclient ​import ​TestClient
from ​sqlalchemy ​import ​create_engine
from ​sqlalchemy.orm ​import ​Session
from ​sqlalchemy.pool ​import ​StaticPool

from ​app.db ​import ​get_db
from ​app.main ​import ​app
from ​app.models ​import ​Base

@pytest.fixture
def ​db_session() ​-> ​Iterator[Session]:
 ​ ​ ​ ​test_engine ​= ​create_engine(
 ​ ​ ​ ​ ​ ​ ​ ​'sqlite://',
 ​ ​ ​ ​ ​ ​ ​ ​connect_args={'check_same_thread': ​False},
 ​ ​ ​ ​ ​ ​ ​ ​poolclass=StaticPool,
 ​ ​ ​ ​)
 ​ ​ ​ ​Base.metadata.create_all(test_engine)

 ​ ​ ​ ​with ​Session(test_engine) ​as ​session:
 ​ ​ ​ ​ ​ ​ ​ ​yield ​session

 ​ ​ ​ ​Base.metadata.drop_all(test_engine)
 ​ ​ ​ ​test_engine.dispose()

@pytest.fixture
def ​client(db_session: ​Session) ​-> ​Iterator[TestClient]:
 ​ ​ ​ ​def ​override_get_db():
 ​ ​ ​ ​ ​ ​ ​ ​yield ​db_session

 ​ ​ ​ ​app.dependency_overrides[get_db] ​= ​override_get_db
 ​ ​ ​ ​try:
 ​ ​ ​ ​ ​ ​ ​ ​with ​TestClient(app) ​as ​test_client:
 ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​yield ​test_client
 ​ ​ ​ ​finally:
 ​ ​ ​ ​ ​ ​ ​ ​app.dependency_overrides.clear()

db_sessionはテストごとに新しいDBを作るため、前のテストのデータが残りません。

clientは同じセッションをFastAPIへ渡し、終了時に依存関係の差し替えを必ず解除します。

アプリが起動時と終了時の処理を持つ場合に備え、TestClientはwith文で開いています。

最初のAPIテスト

作成と取得を一つのテストにすると、リクエスト、DB保存、レスポンス変換のつながりを短く確認できます。

# ​tests/test_todos.py
def ​test_create_and_get_todo(client):
 ​ ​ ​ ​created ​= ​client.post(
 ​ ​ ​ ​ ​ ​ ​ ​'/todos',
 ​ ​ ​ ​ ​ ​ ​ ​json={'title': ​'書類を出す'},
 ​ ​ ​ ​)
 ​ ​ ​ ​assert ​created.status_code ​== ​201

 ​ ​ ​ ​created_data ​= ​created.json()
 ​ ​ ​ ​assert ​created_data['title'] ​== ​'書類を出す'
 ​ ​ ​ ​assert ​created_data['is_done'] ​is ​False

 ​ ​ ​ ​fetched ​= ​client.get(f"/todos/{created_data['id']}")
 ​ ​ ​ ​assert ​fetched.status_code ​== ​200
 ​ ​ ​ ​assert ​fetched.json()['id'] ​== ​created_data['id']

このテストが失敗したときは、まずステータスコードを確認し、その後にJSONの各項目を追います。

一つの巨大な比較だけにすると、どの仕様が崩れたのか見分けにくくなります。

フィクスチャで投入したデータをAPIから読む

DBへ準備したデータとAPIが同じセッションを使っているかも検証します。

from ​app.models ​import ​Todo

def ​test_list_todos(db_session, ​client):
 ​ ​ ​ ​db_session.add_all([
 ​ ​ ​ ​ ​ ​ ​ ​Todo(title='A'),
 ​ ​ ​ ​ ​ ​ ​ ​Todo(title='B', ​is_done=True),
 ​ ​ ​ ​])
 ​ ​ ​ ​db_session.commit()

 ​ ​ ​ ​response ​= ​client.get('/todos')

 ​ ​ ​ ​assert ​response.status_code ​== ​200
 ​ ​ ​ ​assert ​[item['title'] ​for ​item ​in ​response.json()] ​== ​['A', ​'B']

元のDBへデータを入れながら、APIだけ別のDBを読む構成では、このテストは通りません。

データ投入用フィクスチャと依存関係の差し替えを同じdb_sessionへ結ぶことが必要です。

失敗系と境界値を仕様として残す

正常系だけでは、入力が不正なときの契約を確認できません。

存在しないIDと長すぎるタイトルを例に、404と422を明示します。

def ​test_get_todo_not_found(client):
 ​ ​ ​ ​response ​= ​client.get('/todos/9999')

 ​ ​ ​ ​assert ​response.status_code ​== ​404
 ​ ​ ​ ​assert ​response.json() ​== ​{'detail': ​'Todo ​not ​found'}

def ​test_rejects_too_long_title(client):
 ​ ​ ​ ​response ​= ​client.post(
 ​ ​ ​ ​ ​ ​ ​ ​'/todos',
 ​ ​ ​ ​ ​ ​ ​ ​json={'title': ​'a' ​* ​201},
 ​ ​ ​ ​)

 ​ ​ ​ ​assert ​response.status_code ​== ​422

FastAPIのリクエスト検証に失敗した場合、この構成では422が返ります。

「不正入力はすべて400」と決めつけず、実際のAPI契約に合わせて期待値を書きます。

型定義と依存性注入をAPI契約として扱う方法を合わせて読むと、何をテストへ残すべきか整理しやすくなります。

更新と削除を一つの利用経路で確かめる

def ​test_update_and_delete(client):
 ​ ​ ​ ​created ​= ​client.post('/todos', ​json={'title': ​'初期'})
 ​ ​ ​ ​todo_id ​= ​created.json()['id']

 ​ ​ ​ ​updated ​= ​client.put(
 ​ ​ ​ ​ ​ ​ ​ ​f'/todos/{todo_id}',
 ​ ​ ​ ​ ​ ​ ​ ​json={'title': ​'更新後', ​'is_done': ​True},
 ​ ​ ​ ​)
 ​ ​ ​ ​assert ​updated.status_code ​== ​200
 ​ ​ ​ ​assert ​updated.json()['title'] ​== ​'更新後'
 ​ ​ ​ ​assert ​updated.json()['is_done'] ​is ​True

 ​ ​ ​ ​deleted ​= ​client.delete(f'/todos/{todo_id}')
 ​ ​ ​ ​assert ​deleted.status_code ​== ​204
 ​ ​ ​ ​assert ​deleted.content ​== ​b''

 ​ ​ ​ ​fetched ​= ​client.get(f'/todos/{todo_id}')
 ​ ​ ​ ​assert ​fetched.status_code ​== ​404

削除処理では204だけでなく、レスポンス本文が空であることと、その後に取得できないことも確かめます。

AsyncClientで非同期テストを書く

テスト関数内で非同期DBや別の非同期関数を直接待機したい場合は、HTTPXのAsyncClientを使います。

FastAPIアプリを直接渡すのではなく、ASGITransportを介して接続します。

# ​tests/test_async.py
import ​pytest
from ​httpx ​import ​ASGITransport, ​AsyncClient

from ​app.db ​import ​get_db
from ​app.main ​import ​app

@pytest.fixture
def ​anyio_backend():
 ​ ​ ​ ​return ​'asyncio'

@pytest.mark.anyio
async ​def ​test_list_todos_async(db_session):
 ​ ​ ​ ​def ​override_get_db():
 ​ ​ ​ ​ ​ ​ ​ ​yield ​db_session

 ​ ​ ​ ​app.dependency_overrides[get_db] ​= ​override_get_db
 ​ ​ ​ ​try:
 ​ ​ ​ ​ ​ ​ ​ ​transport ​= ​ASGITransport(app=app)
 ​ ​ ​ ​ ​ ​ ​ ​async ​with ​AsyncClient(
 ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​transport=transport,
 ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​base_url='http://test',
 ​ ​ ​ ​ ​ ​ ​ ​) ​as ​client:
 ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​response ​= ​await ​client.get('/todos')
 ​ ​ ​ ​finally:
 ​ ​ ​ ​ ​ ​ ​ ​app.dependency_overrides.clear()

 ​ ​ ​ ​assert ​response.status_code ​== ​200
 ​ ​ ​ ​assert ​response.json() ​== ​[]

AsyncClientだけでは、アプリのlifespan処理は自動で実行されません。

起動時にDB接続や共有リソースを準備するアプリでは、その処理をテストでどう開始するかを別途決めます。

設計の背景は、FastAPIのlifespanで共有リソースを管理する方法で確認できます。

認証と外部サービスは境界で差し替える

認証サービス、メール送信、決済、外部APIを毎回呼ぶと、テストが遅くなり、ネットワーク状態にも左右されます。

FastAPIの依存関係として切り出せる処理は、app.dependency_overridesでテスト用実装へ差し替えます。

@pytest.fixture
def ​as_admin():
 ​ ​ ​ ​def ​fake_current_user():
 ​ ​ ​ ​ ​ ​ ​ ​return ​{'username': ​'alice', ​'role': ​'admin'}

 ​ ​ ​ ​app.dependency_overrides[get_current_user] ​= ​fake_current_user
 ​ ​ ​ ​try:
 ​ ​ ​ ​ ​ ​ ​ ​yield
 ​ ​ ​ ​finally:
 ​ ​ ​ ​ ​ ​ ​ ​app.dependency_overrides.pop(get_current_user, ​None)

この例のget_current_userは、アプリ側にある認証依存を表します。

差し替え後は、管理者として成功する経路だけでなく、未認証の401と権限不足の403も別のテストで確かめます。

モックするのは自分の処理すべてではなく、外部との境界です。

そうすれば、テスト対象の判断ロジックは実際に動かしながら、副作用だけを抑えられます。

データを初期化する3つの方法

テストデータの後片付けには複数の方法があり、DBと実行方式に応じて選びます。

方法 長所 注意点
テストごとにDBを作る 分離が明確で理解しやすい スキーマが大きいと遅くなる
外側のトランザクションを戻す 作り直しを減らせる アプリ内のcommitやネストしたトランザクションとの整合が必要
専用DBのテーブルを初期化する 本番と同じDB製品を使いやすい 並列実行時のDB分離と初期化順序を決める必要がある

入門段階では、この記事のようにテストごとに小さなメモリDBを作る方法が追いやすい構成です。

テスト数が増えて遅くなったら、計測してからトランザクション方式や専用DBへ移します。

テスト名とArrange、Act、Assert

テストは失敗したときに読まれるコードです。

名前だけで前提、操作、期待結果がわかるようにします。

準備が長くなる場合はフィクスチャやファクトリへ移します。

ただし、テストの意図まで隠れるほど抽象化すると、失敗時に追いにくくなります。

カバレッジとCIの使い方

未実行の行を確認するには、pytest-covを使います。

pytest ​--cov=app ​--cov-report=term-missing ​-q

カバレッジ率は、どの行が通っていないかを探す手がかりです。

高い数値だけでは、境界値や失敗時の振る舞いまで検証できているとは限りません。

まずは認証、権限、データ更新、外部連携など、障害時の影響が大きい経路を優先します。

実行時間を調べるときは、次のコマンドで遅いテストを確認できます。

pytest ​--durations=10 ​-q

ローカルで安定したら、プルリクエストごとに同じコマンドをCIで実行します。

テストファイルが増えた段階では、FastAPIのプロジェクト構成を保守しやすく分ける方法も役立ちます。

よくある失敗と確認箇所

症状 確認する箇所
no ​such ​tableが出る テスト用テーブルを作成したか、メモリSQLiteの接続を共有しているか
単独では通るが一括実行で失敗する 前のテストのデータや依存関係の差し替えが残っていないか
不正入力で422になる Pydanticの入力検証に失敗していないか、期待するAPI契約が422か

導入を6段階に分ける

  1. 起動確認かヘルスチェックを1本書きます。
  2. 代表的な作成と取得の経路を追加します。
  3. 404、422、401、403などの失敗系を仕様として残します。
  4. テスト用DBと依存関係の差し替えをフィクスチャへまとめます。
  5. 外部サービスとの境界をモックし、タイムアウトや失敗時の処理を確かめます。
  6. カバレッジと実行時間を見ながら、CIで毎回実行します。

最初からすべての経路を埋める必要はありません。

変更する機能の正常系と失敗系を追加し、バグ修正では再現テストを先に残すと、テスト群が実際の仕様に沿って育ちます。

FastAPIの自動テストで押さえる要点

次の一歩は、既存APIの代表的なエンドポイントを一つ選び、作成と取得が通る短いテストを書くことです。

その1本が安定して動けば、失敗系、認証、外部連携へ無理なく広げられます。

この記事に関連する株式会社greedenの取り組み

APIの品質は、実装後にテストを足すだけでは安定しません。株式会社greedenは、要件定義から設計、実装、単体、結合、受入テスト、保守まで一貫して支援し、仕様変更を安全に重ねられるWebシステム開発を進めます。

モバイルバージョンを終了