green snake
Photo by Pixabay on Pexels.com
目次

FastAPI×pytest徹底ガイド:ユニットテスト・APIテスト・統合テスト・モック戦略で「壊しても怖くない」APIを育てる


最初にざっくり全体像(要約)

  • FastAPIアプリを安全に育てるには、ユニットテスト・APIテスト・統合テストをバランスよく組み合わせることが大切です。
  • テストの中心には pytest を据え、FastAPIが用意する TestClient や依存関係の上書き(dependency override)機能を組み合わせることで、HTTP越しの振る舞いも簡単に検証できます。
  • ドメインロジック(サービス層)やリポジトリはモックやテスト用SQLiteを利用して、DBや外部APIに依存せずにすばやくテストできるようにします。
  • 認証・認可・JWT・スコープなどのセキュリティ回りも、「正しいトークン」「誤ったトークン」「権限不足」をテストで守ることで、安心して機能追加や仕様変更を行えるようになります。
  • 最後に、CI(GitHub Actionsなど)とつなげて「プッシュしたら自動でテストが回る」状態にし、いつ壊してもすぐに分かる開発サイクルを目指します。

誰が読んで得をするか(具体的なイメージ)

個人開発・学習者さん

  • FastAPIでAPIを作ってみたけれど、テストはまだほとんど書いていない。
  • 毎回ブラウザやcurlで手動確認していて、「ちょっと修正するたびに怖い」と感じている。
  • pytestの基本はなんとなく知っているけれど、「FastAPIだとどう組み合わせればよいの?」がふわっとしている。

この方には、「まずはここから」の最小テスト構成と、ユニットテスト・APIテストの書き分け方が役に立ちます。

小規模チームのバックエンドエンジニアさん

  • 3〜5名くらいのチームでFastAPIを開発していて、「人によってテストの書き方がバラバラ」になりつつある。
  • サービス層・リポジトリ層・ルーターが分かれているが、どこをどのレイヤーでテストするのがよいか整理したい。
  • 認証・認可まわりの仕様変更が頻繁に入るので、そこをテストでしっかり守りたい。

この方には、テストピラミッド(ユニット>API>統合)を意識した設計と、依存関係の上書き・モック戦略のパターンが有効です。

SaaS開発チーム・スタートアップの皆さま

  • プロダクトの成長スピードが速く、毎日のようにデプロイしている。
  • バグが出たときに「同じ不具合を二度と出さない」ための回帰テストを積み上げたい。
  • CI/CDとの連携も含めて、「テスト方針をチームで共有できる形」に落とし込みたい。

この方には、テストの役割を整理しながら、pytestを中心にテストポリシーの型を作るヒントをお渡しできればと思います。


アクセシビリティ評価(読みやすさ・構成の配慮)

  • 記事構成は「全体像 → 準備 → ユニットテスト → APIテスト → DB・リポジトリ → 認証・認可 → 非同期・バックグラウンド → フィクスチャ設計 → CI連携 → ロードマップ」という段階的な流れにしました。
  • 各セクションは2〜3段落に分け、長すぎる説明は避けつつ、要点とサンプルコードをまとめています。
  • コードブロックは固定幅で表示し、コメントは必要最低限にとどめて視線が迷わないようにしています。
  • 説明用語は初出で短く解説し、そのあとは同じ表現を使い続けることで認知負荷を下げています。

全体として、技術記事としてWCAG AA相当の読みやすさを目標にしています。


1. まず「何をテストしたいのか」を整理する

いきなり細かいテクニックに入る前に、「FastAPIアプリで何をテストしたいのか」をざっくり整理しておきましょう。

1.1 テストの3つのレイヤー

FastAPIに限らずWebアプリでは、次の3種類のテストを意識しておくと考えやすくなります。

  1. ユニットテスト(Unit Test)

    • 小さな関数やクラスが、期待通りに振る舞うかを確認します。
    • DB・ネットワーク・FastAPI本体には依存せず、純粋なPythonとしてテストするのが理想です。
    • 例:料金計算、権限判定、サービス層のドメインロジック。
  2. APIテスト(Integration/API Test)

    • FastAPIのルーターを通して、実際のHTTPリクエストと同じ形で振る舞いを確認します。
    • ただしDBや外部APIはテスト用に切り替えたりモックしたりして、早く・繰り返し実行できるようにします。
  3. 統合テスト・E2Eテスト

    • 本番に近い構成(本物のDB・外部サービス・フロントエンド)で「全体として」動くかを確認します。
    • ここはコストが高いので、数は絞りつつ重要なシナリオだけに絞ります。

この記事では、中心になる ユニットテストとAPIテスト に焦点を当てますね。

1.2 テストピラミッドの考え方

よく言われる「テストピラミッド」は、

  • 底辺:ユニットテスト(数が多くて早い)
  • 中段:APIテスト・統合テスト(ほどほどの数)
  • 上段:E2Eテスト(数は少ないけれど重要)

というバランスを取るべき、という考え方です。

FastAPIアプリでも、いきなりすべてをE2Eテストでカバーしようとすると、

  • テストが遅くて誰も回さなくなる
  • 壊れたときに「どこが原因か」が分かりづらい

といった問題が出てきます。

ですので、ドメインロジックはユニットテストでしっかり守る、その上でルーターや認証などHTTP特有の部分をAPIテストで確認する、という役割分担を意識していきます。


2. テストの準備:pytestとFastAPIの基本セット

2.1 pytestの導入と基本コマンド

FastAPI公式ドキュメントでも、テストフレームワークには pytest が推奨されています。
すでに導入済みの方も多いと思いますが、一応基本だけ確認しておきます。

pip install pytest
  • テストファイル名:test_*.py または *_test.py
  • テスト関数名:test_ で始まる関数
  • 実行:プロジェクトルートで pytest を実行
pytest             # すべてのテストを実行
pytest tests/api   # 特定ディレクトリ配下だけ
pytest -k "login"  # 名前に"login"を含むテストだけ実行

2.2 FastAPIのTestClient

FastAPIはStarletteベースなので、テスト用に TestClient が提供されています。

from fastapi.testclient import TestClient
from app.main import app

client = TestClient(app)

def test_read_root():
    res = client.get("/")
    assert res.status_code == 200
    assert res.json() == {"message": "Hello, World"}

TestClient は同期的なインターフェース(get / post など)を持っているため、
pytestの通常の関数型テストでも扱いやすくなっています。

非同期テストが必要な場合は、httpx.AsyncClientpytest-asyncio を組み合わせる方法もありますが、
まずは TestClient から始めると良いかなと思います。


3. ユニットテスト:サービス層・ドメインロジックをしっかり守る

まずは、FastAPIに依存しない純粋なロジック部分(サービス層)から整えていきましょう。

3.1 料金計算のような純粋関数

分かりやすい例として、金額計算の関数をテストしてみます。

# app/domain/billing/service.py
def calc_price(base: int, discount_rate: float) -> int:
    if not (0 <= discount_rate <= 1):
        raise ValueError("discount_rate must be between 0 and 1")
    price = int(base * (1 - discount_rate))
    if price < 0:
        price = 0
    return price

テストはとてもシンプルです。

# tests/domain/test_billing_service.py
from app.domain.billing.service import calc_price
import pytest

def test_calc_price_normal():
    assert calc_price(1000, 0.2) == 800

def test_calc_price_zero():
    assert calc_price(1000, 1.0) == 0

def test_calc_price_invalid_discount():
    with pytest.raises(ValueError):
        calc_price(1000, 1.5)

このレベルのテストが積み上がっているだけでも、ちょっとした修正でビジネスロジックを壊していないかが分かるようになります。

3.2 サービス層+モックリポジトリ

先ほどのクリーンアーキテクチャの記事で触れたような「サービス層+リポジトリ」の構成を前提に、サービス層だけをテストしてみましょう。

# app/domain/articles/services.py
from dataclasses import dataclass
from app.domain.articles.models import Article
from app.domain.articles.schemas import ArticleCreate, ArticleRead

class ArticleRepositoryProtocol:
    def add(self, article: Article) -> Article: ...
    def get(self, article_id: int) -> Article | None: ...

@dataclass
class ArticleService:
    repo: ArticleRepositoryProtocol

    def create_article(self, author_id: int, data: ArticleCreate) -> ArticleRead:
        article = Article(
            title=data.title,
            body=data.body,
            author_id=author_id,
            status="draft",
        )
        saved = self.repo.add(article)
        return ArticleRead.model_validate(saved)

テストでは、DBにはつながず「テスト用のリポジトリ実装」を渡します。

# tests/domain/test_article_service.py
from app.domain.articles.services import ArticleService
from app.domain.articles.schemas import ArticleCreate
from app.domain.articles.models import Article

class InMemoryArticleRepo:
    def __init__(self):
        self.items: list[Article] = []
        self._id = 1

    def add(self, article: Article) -> Article:
        article.id = self._id
        self._id += 1
        self.items.append(article)
        return article

    def get(self, article_id: int) -> Article | None:
        for a in self.items:
            if a.id == article_id:
                return a
        return None

def test_create_article_sets_draft_status():
    repo = InMemoryArticleRepo()
    service = ArticleService(repo=repo)

    data = ArticleCreate(title="Hello", body="World")
    result = service.create_article(author_id=42, data=data)

    assert result.status == "draft"
    assert result.title == "Hello"
    assert result.author_id == 42

こうしておくと、仕様変更でサービス層をいじったときに、
テストが「意図しない挙動の変化」をすぐに教えてくれるようになります。


4. APIテスト:FastAPIのエンドポイントを通して振る舞いを確認する

次は、HTTP越しの挙動をテストするAPIテストです。

4.1 シンプルなCRUDエンドポイントのテスト

例として、記事の一覧と作成APIを用意します。

# app/api/v1/articles.py
from fastapi import APIRouter, Depends
from app.domain.articles.schemas import ArticleCreate, ArticleRead
from app.domain.articles.services import ArticleService
from app.deps.services import get_article_service

router = APIRouter(prefix="/articles", tags=["articles"])

@router.get("", response_model=list[ArticleRead])
def list_articles(
    service: ArticleService = Depends(get_article_service),
):
    return service.list_articles()

@router.post("", response_model=ArticleRead, status_code=201)
def create_article(
    payload: ArticleCreate,
    service: ArticleService = Depends(get_article_service),
):
    return service.create_article(author_id=1, data=payload)

テストでは TestClient を使います。

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

client = TestClient(app)

def test_create_and_list_articles():
    res = client.post("/articles", json={"title": "T1", "body": "B1"})
    assert res.status_code == 201
    created = res.json()
    assert created["id"] is not None
    assert created["title"] == "T1"

    res_list = client.get("/articles")
    assert res_list.status_code == 200
    items = res_list.json()
    assert len(items) >= 1

4.2 依存関係の上書き(dependency override)

実務では、APIテスト時に本番と同じDBに接続したくないですよね。
FastAPIには app.dependency_overrides という仕組みがあり、テスト中だけ依存関数を書き換えることができます。

# tests/api/conftest.py
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker

from app.main import app
from app.infra.db.base import Base
from app.infra.db.base import get_db  # 本番用の依存関数

TEST_DATABASE_URL = "sqlite:///./test.db"

engine = create_engine(TEST_DATABASE_URL, connect_args={"check_same_thread": False})
TestingSessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False)

@pytest.fixture(scope="session", autouse=True)
def setup_database():
    Base.metadata.create_all(bind=engine)
    yield
    Base.metadata.drop_all(bind=engine)

def override_get_db():
    db = TestingSessionLocal()
    try:
        yield db
    finally:
        db.close()

@pytest.fixture()
def client():
    app.dependency_overrides[get_db] = override_get_db
    client = TestClient(app)
    yield client
    app.dependency_overrides.clear()

これで、APIテストからは client フィクスチャを使うだけで、
本番とは別のSQLiteテストDBを経由してエンドポイントを叩けるようになります。

# tests/api/test_articles_api.py(書き直し)
def test_create_article_uses_test_db(client: TestClient):
    res = client.post("/articles", json={"title": "T1", "body": "B1"})
    assert res.status_code == 201

このパターンは、DB依存のAPIテストにはほぼ必須と言っていいくらい便利なので、
ぜひ自分のプロジェクト用の雛形を作っておくと良いと思います。


5. DB・リポジトリ層のテスト:トランザクションとロールバック

DBを使ったテストでは、「テストごとに状態をきれいにしたい」という悩みがつきまといます。

5.1 テーブルの作成・削除を1回にまとめる

さきほどの setup_database フィクスチャのように、

  • session スコープでテーブル作成・削除を行う
  • 個々のテストではトランザクションをロールバックする

というパターンが定番です。

# tests/db/conftest.py(トランザクション利用パターンの一例)
@pytest.fixture()
def db_session():
    connection = engine.connect()
    transaction = connection.begin()
    session = TestingSessionLocal(bind=connection)

    try:
        yield session
    finally:
        session.close()
        transaction.rollback()
        connection.close()

テストではこの db_session を使ってリポジトリを直接テストします。

# tests/domain/test_article_repository.py
from app.infra.articles.sqlalchemy_repo import SqlAlchemyArticleRepository
from app.domain.articles.models import Article

def test_add_article(db_session):
    repo = SqlAlchemyArticleRepository(db_session)
    article = Article(title="T", body="B", author_id=1, status="draft")
    saved = repo.add(article)

    assert saved.id is not None

    again = repo.get(saved.id)
    assert again is not None
    assert again.title == "T"

トランザクションをロールバックしているので、テストが増えてもDBの状態はきれいなままです。


6. 認証・認可・JWTのテスト

セキュリティまわりは、テストで守られていると安心感が段違いです。

6.1 ログインAPIのテスト

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

client = TestClient(app)

def test_login_success():
    res = client.post(
        "/auth/token",
        data={"username": "alice", "password": "password123"},
    )
    assert res.status_code == 200
    body = res.json()
    assert "access_token" in body
    assert body["token_type"] == "bearer"

def test_login_failure():
    res = client.post(
        "/auth/token",
        data={"username": "alice", "password": "wrong"},
    )
    assert res.status_code == 401

フォームデータ(data=)で送ることや、
誤ったパスワードで401が返ることを確認しておきます。

6.2 保護ルート+トークン付きヘッダのテスト

def get_token_for(username: str, password: str) -> str:
    res = client.post(
        "/auth/token",
        data={"username": username, "password": password},
    )
    assert res.status_code == 200
    return res.json()["access_token"]

def test_protected_route_requires_token():
    res = client.get("/me")
    assert res.status_code == 401

def test_protected_route_with_valid_token():
    token = get_token_for("alice", "password123")
    res = client.get("/me", headers={"Authorization": f"Bearer {token}"})
    assert res.status_code == 200
    body = res.json()
    assert body["username"] == "alice"

6.3 スコープ付きルートのテスト

スコープ(権限)が不足している場合、403が返るかどうかも確認します。

def test_requires_write_scope():
    # "articles:read" だけを持つトークンを作るなど、テスト用のヘルパーを用意
    token = get_token_for("alice", "password123")  # 例としてreadだけのユーザー
    res = client.post(
        "/articles",
        json={"title": "T1", "body": "B1"},
        headers={"Authorization": f"Bearer {token}"},
    )
    assert res.status_code in (401, 403)

権限まわりは仕様変更が入りやすいので、
テスト名を**仕様書に対応させる(例:test_admin_can_delete_user)**と後から読みやすくなります。


7. 非同期処理・バックグラウンド処理のテスト

FastAPIは非同期I/Oやバックグラウンド処理とも相性が良いですが、テストで少し気をつけるポイントがあります。

7.1 BackgroundTasks を使った処理

BackgroundTasks はレスポンス後に実行されるので、ユニットテストとしては「タスクの関数自体」をテストするのがおすすめです。

# app/tasks/audit.py
def write_audit_log(user_id: int, action: str) -> None:
    # 実際にはファイルやDBに書く処理
    print(f"{user_id} {action}")
# tests/tasks/test_audit.py
from app.tasks.audit import write_audit_log

def test_write_audit_log_runs_without_error(capsys):
    write_audit_log(1, "login")
    captured = capsys.readouterr()
    assert "login" in captured.out

APIテストでは、「タスクが登録されるか」をテストし、
タスクの中身そのものは別のテストで検証する形が現実的です。

7.2 Celeryなどのジョブキュー

Celeryを使っている場合は、task_always_eager=True のような「テストモード」を使うと、
ワーカーを立てずにタスクを同期的に実行できます。

# tests/conftest.py(例)
from app.celery_app import celery_app

def pytest_configure():
    celery_app.conf.update(task_always_eager=True)
# tests/tasks/test_long_add.py
from app.tasks import long_add

def test_long_add():
    res = long_add.delay(1, 2)
    assert res.result == 3

本番では非同期・分散処理ですが、テストでは同期的に走らせるという割り切りも、扱いやすさの面では大切です。


8. フィクスチャとテストデータ:読みやすく・再利用しやすく

pytestの強みのひとつが、**フィクスチャ(fixture)**によるテストデータ・状態の共有です。

8.1 ユーザー作成用フィクスチャ

# tests/fixtures/users.py
import pytest
from sqlalchemy.orm import Session
from app.models.user import User
from app.core.security import hash_password

@pytest.fixture
def user_alice(db_session: Session) -> User:
    alice = User(
        username="alice",
        hashed_password=hash_password("password123"),
        is_active=True,
    )
    db_session.add(alice)
    db_session.commit()
    db_session.refresh(alice)
    return alice

テストではこれを受け取るだけで、
「aliceというユーザーがDBに存在する」という前提がすぐに再利用できます。

# tests/api/test_auth_api.py
def test_login_with_fixture(client, user_alice):
    res = client.post(
        "/auth/token",
        data={"username": "alice", "password": "password123"},
    )
    assert res.status_code == 200

8.2 Factoryパターン

テストデータが増えてくると、factory_boymodel_bakery のようなライブラリを使うと便利です。
ただ、最初のうちは シンプルなヘルパー関数 で十分なケースも多いので、無理に導入する必要はありません。


9. CIとの連携:テストを「必ず回るもの」にする

ローカルでテストを書くだけでなく、CI(継続的インテグレーション)と組み合わせることで、
「誰かがプッシュ or PRを出したら、自動的にテストが走る」状態をつくりたいですね。

9.1 GitHub Actionsの最小構成(おさらい)

# .github/workflows/tests.yaml
name: Run tests

on:
  push:
    branches: ["main"]
  pull_request:
    branches: ["main"]

jobs:
  test:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-python@v5
        with:
          python-version: "3.11"

      - name: Install deps
        run: |
          python -m pip install --upgrade pip
          pip install -r requirements.txt
          pip install -r requirements-dev.txt

      - name: Run pytest
        run: pytest

これで、GitHub上のPRやmainブランチへのコミットごとに、pytestが自動で実行されます。
「テストが通らないコードはマージしない」というルールをチームで共有できると、とても心強いです。


10. よくあるつまずきと、優しめの対策

最後に、FastAPI+pytestでよくある「つまずきポイント」と、その避け方を簡単にまとめておきますね。

症状 原因 対策
テストが遅くて誰も回さない 毎回本番DB・外部APIに接続している テスト用DBやモックを用意し、依存関係の上書き(override)を活用する
テストの失敗原因が分かりづらい 1つのテストが長く、いろいろなことをしている 「1テスト1アサーション」まではいかなくても、1つのテストで確認することをできるだけ絞る
認証付きAPIのテストが面倒 毎回ログインAPIを叩いてトークンを取っている トークン取得用ヘルパー関数や、認証済みクライアントを返すフィクスチャを作る
DB状態がテスト間で污染される テストごとにロールバックしていない トランザクション+ロールバックパターンや、テスト用SQLiteを採用する
ユニットテストとAPIテストの境界があいまい どこまでをどのレイヤーでテストするか決めていない ドメインロジックはサービス層のユニットテストで、HTTP特有の部分はAPIテストで、という「ざっくり方針」を決める

11. 導入ロードマップ(少しずつ育てるために)

最後に、「これからテストを整えていくときの道筋」を段階的にまとめておきます。

  1. pytestを導入し、1つだけでもユニットテストを書く

    • たとえば料金計算など、「分かりやすい純粋関数」から始めると気楽です。
  2. FastAPIのTestClientで、1つだけでもAPIテストを書く

    • /health/hello のような簡単なエンドポイントで、「テストからHTTPを叩く」感覚をつかみます。
  3. DB依存のAPIテスト用に、テストDB・依存関数overrideを導入する

    • SQLite+トランザクションパターンを使って、CRUD APIのテストを書いてみましょう。
  4. サービス層+モックリポジトリのユニットテストを増やす

    • 重要なビジネスロジックを、FastAPIやDBから切り離してテストします。
  5. 認証・認可のテストを追加する

    • ログイン成功・失敗、トークンあり/なし、権限不足のときの挙動をテストで固めます。
  6. CIと連携し、「プッシュ時に自動テスト」が回るようにする

    • 少なくともmainブランチへのマージ前にテストが回る状態を目指します。
  7. 必要に応じて、Factory・モックライブラリ・非同期テストなどを拡張

    • プロジェクトの規模や複雑さに応じて、テストの道具箱を増やしていきます。

まとめ

  • FastAPIアプリのテストは、pytest+TestClient+依存関係override を基本セットとして、ユニットテストとAPIテストをバランスよく組み合わせるのがポイントです。
  • ドメインロジックはサービス層のユニットテストでしっかり守り、HTTPヘッダ・ステータスコード・JSONレスポンスなどの「外向きの振る舞い」はAPIテストで確認します。
  • DBや外部APIはテスト用のSQLiteやモック/テストダブルに置き換え、テストを「早く・繰り返し」実行できるようにすることが、長く続けられるテスト運用の鍵になります。
  • 認証・認可・JWT・スコープなど、セキュリティ回りもテストで覆っておくと、安心して仕様変更や機能追加ができるようになります。
  • すべてを一度に整えようとしなくて大丈夫です。まずは1つの純粋関数のテスト、1つのAPIテストから始めて、少しずつ「壊しても怖くない」FastAPIアプリに育てていきましょう。

わたしも、あなたのプロジェクトがテストに守られて、もっと自由に実験できるようになることを、そっと応援しています。


投稿者 greeden

コメントを残す

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

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