目次
28 章

テストとデプロイ — pytest、Docker、Railway/Fly

pytest + httpx で FastAPI 統合テスト、依存性オーバーライドで隔離、Docker のマルチステージビルド、Railway/Fly クラウドデプロイまでまとめます。

4部 (ソースから構成した 6 章) の最後 — テストとデプロイ です。作った API が本当に動作するかを自動で検証し、1 つのコマンドでコンテナをビルドしてクラウドに上げる流れまでをまとめます。

本章は第30章 型チェッカ設定と CI 統合、第31章 logging と観測性 とひとまとまりで運用されます。本章が「テスト + デプロイの表層」、5部の章が「運用の深さ」です。そして第29章 総合実習 — TODO API を完成させる が本章までのすべてのパターンを一つのサービスにまとめます。

pytest + httpx — FastAPI テストの標準 #

FastAPI は TestClient (httpx ベース) という in-process テストクライアントを提供します。実際の HTTP サーバーを立ち上げずにルートを呼び出せます。

インストール (すでに第22章で追加済み)
uv add --dev pytest pytest-asyncio httpx

最初のテスト #

tests/test_root.py
from fastapi.testclient import TestClient
from app.main import app

client = TestClient(app)

def test_health():
    response = client.get("/health")
    assert response.status_code == 200
    assert response.json() == {"status": "healthy"}
実行
uv run pytest -v

TestClient は同期インターフェースを提供します。ルートが async def でもそのまま呼び出し。

非同期テスト — httpx.AsyncClient #

非同期フィクスチャ / ライブラリを直接使うなら AsyncClient

tests/conftest.py
import pytest
import pytest_asyncio
from httpx import AsyncClient, ASGITransport
from app.main import app

@pytest_asyncio.fixture
async def client() -> AsyncClient:
    async with AsyncClient(
        transport=ASGITransport(app=app),
        base_url="http://test",
    ) as c:
        yield c
tests/test_root_async.py
import pytest

@pytest.mark.asyncio
async def test_health(client):
    response = await client.get("/health")
    assert response.status_code == 200

pytest.ini または pyproject.toml で非同期モードを設定。

pyproject.toml
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]

asyncio_mode = "auto" にすると、すべての async def test_xxx が自動で非同期として実行されます — @pytest.mark.asyncio デコレータは省略可能。

依存性オーバーライド — 外部依存性の隔離 #

テスト時に本物の DB / 外部 API を使うと遅く、変動性があります。依存性オーバーライド が標準解です。

tests/conftest.py 拡張
import pytest_asyncio
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
from app.api.deps import get_session
from app.models.base import Base
from app.main import app

TEST_DATABASE_URL = "sqlite+aiosqlite:///:memory:"

@pytest_asyncio.fixture
async def db_engine():
    engine = create_async_engine(TEST_DATABASE_URL)
    async with engine.begin() as conn:
        await conn.run_sync(Base.metadata.create_all)
    yield engine
    await engine.dispose()

@pytest_asyncio.fixture
async def db_session(db_engine) -> AsyncSession:
    async_session = async_sessionmaker(db_engine, expire_on_commit=False)
    async with async_session() as session:
        yield session

@pytest_asyncio.fixture
async def client(db_session):
    async def override_get_session():
        yield db_session

    app.dependency_overrides[get_session] = override_get_session

    async with AsyncClient(
        transport=ASGITransport(app=app),
        base_url="http://test",
    ) as c:
        yield c

    app.dependency_overrides.clear()

核心: app.dependency_overrides[元の依存性] = 代替 の一行が 第25章 で見た get_session 依存性をテスト版に差し替えます。ルートコードは一行も変わりません。これが依存性注入の最大の効用 です。

認証の迂回 #

第26章 認証get_current_user も同じ方式で:

auth の迂回
from app.api.deps import get_current_user
from app.models.user import User

@pytest_asyncio.fixture
async def authed_client(client, db_session):
    test_user = User(id=1, email="test@example.com", hashed_password="x")
    db_session.add(test_user)
    await db_session.commit()

    async def override_user():
        return test_user

    app.dependency_overrides[get_current_user] = override_user
    yield client

テストが毎回トークンを作る必要なく、認証された状態で開始。

ルートテストパターン #

tests/test_todos.py
import pytest

@pytest.mark.asyncio
async def test_create_todo(authed_client):
    response = await authed_client.post(
        "/todos/",
        json={"title": "pytest 学習"},
    )
    assert response.status_code == 201
    data = response.json()
    assert data["title"] == "pytest 学習"
    assert data["done"] is False
    assert "id" in data
    assert "created_at" in data

@pytest.mark.asyncio
async def test_create_todo_validates_title(authed_client):
    response = await authed_client.post(
        "/todos/",
        json={"title": ""},   # min_length=1
    )
    assert response.status_code == 422

@pytest.mark.asyncio
async def test_get_todo_not_found(authed_client):
    response = await authed_client.get("/todos/99999")
    assert response.status_code == 404

テストの形 — AAA (Arrange / Act / Assert) が自然なパターン:

  • Arrange — フィクスチャが事前に準備
  • Act — client.post(...)
  • Assert — レスポンスの検証

フローテスト #

複数のリクエストが絡むシナリオ:

フローテスト
@pytest.mark.asyncio
async def test_full_todo_lifecycle(authed_client):
    # Create
    create_resp = await authed_client.post("/todos/", json={"title": "test"})
    todo_id = create_resp.json()["id"]

    # Get
    get_resp = await authed_client.get(f"/todos/{todo_id}")
    assert get_resp.json()["title"] == "test"

    # Update
    update_resp = await authed_client.patch(
        f"/todos/{todo_id}",
        json={"done": True},
    )
    assert update_resp.json()["done"] is True

    # Delete
    delete_resp = await authed_client.delete(f"/todos/{todo_id}")
    assert delete_resp.status_code == 204

    # Verify deleted
    final_resp = await authed_client.get(f"/todos/{todo_id}")
    assert final_resp.status_code == 404

外部呼び出しのモック #

第27章 非同期とバックグラウンドジョブ で見た httpx の外部呼び出しは、テストでモックするのがよいです。

インストール
uv add --dev pytest-httpx
外部 API のモック
import pytest

@pytest.mark.asyncio
async def test_external_call(httpx_mock):
    httpx_mock.add_response(
        url="https://api.external.com/data",
        json={"result": "ok"},
    )

    response = await my_function_that_calls_external()
    assert response == "ok"

pytest-httpx がすべての httpx リクエストを横取りして、予め定めたレスポンスを返します。外部依存性がないので速く安定的。

カバレッジ #

カバレッジ測定
uv add --dev coverage pytest-cov
uv run pytest --cov=app --cov-report=term-missing
出力例
Name                         Stmts   Miss  Cover   Missing
----------------------------------------------------------
app/main.py                     12      0   100%
app/api/todos.py                45      3    93%   78-80
app/services/user.py            22      1    95%   45
----------------------------------------------------------
TOTAL                           79      4    95%

100% が目標ではなく 重要な経路が検証されるか が目標です。UI テストと違い API はカバレッジが比較的よく上がります — 80 〜 90% が無難な水準。

統合テスト vs 単体テスト #

種類対象
単体テスト純粋関数、ロジック — test_validate_email()test_calculate_total()
統合テストルート + DB + 依存性 — test_create_todo()
E2E テスト実サーバー + 実 DB — 別環境

API プロジェクトは 統合テストが主力 です。単体は複雑なビジネスロジックにだけ追加、E2E はデプロイ前の smoke test。

Docker — 一貫した環境 #

デプロイするときは コンテナ で束ねてどの環境でも同じように動かします。

Dockerfile — マルチステージビルド #

Dockerfile
# === 1) ビルドステージ — uv で依存関係をインストール ===
FROM python:3.14-slim AS builder

# uv インストール
COPY --from=ghcr.io/astral-sh/uv:latest /uv /bin/uv

WORKDIR /app

# 依存関係だけ先にコピー (キャッシュ効率)
COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-dev

# コードコピー
COPY app/ ./app/

# === 2) ランタイムステージ — 小さいイメージ ===
FROM python:3.14-slim AS runtime

# 非 root ユーザー
RUN useradd --create-home --uid 1000 appuser

WORKDIR /app

# ビルダーから venv とコードをコピー
COPY --from=builder /app/.venv /app/.venv
COPY --from=builder /app/app /app/app

# venv の python を PATH の優先に
ENV PATH="/app/.venv/bin:$PATH"

USER appuser

EXPOSE 8000

CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

核心パターン:

  • マルチステージ — ビルドツールは最終イメージから外れ、イメージサイズが小さくなる
  • 依存関係先、コード後 — コードだけ変わると依存性レイヤキャッシュを再利用
  • 非 root ユーザー — セキュリティ (root 権限コンテナは危険)
  • --frozenuv.lock をそのまま、バージョン決定性保証
  • --no-dev — 開発依存性除外

.dockerignore #

.dockerignore
.venv/
__pycache__/
*.pyc
.pytest_cache/
.git/
.github/
tests/
.env
*.md

イメージに入る必要のないもの。ビルド速度とセキュリティに影響。

ビルドと実行 #

ローカルビルド
docker build -t todo-api:0.1 .
docker run --rm -p 8000:8000 \
  -e DATABASE_URL=postgresql+asyncpg://... \
  -e JWT_SECRET=... \
  todo-api:0.1

docker-compose — ローカル開発環境 #

API + DB + Redis を一緒に立ち上げるとき。

docker-compose.yml
services:
  api:
    build: .
    ports:
      - "8000:8000"
    environment:
      DATABASE_URL: postgresql+asyncpg://user:pw@db:5432/todo
      JWT_SECRET: dev-secret
      REDIS_URL: redis://redis:6379
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_started

  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: user
      POSTGRES_PASSWORD: pw
      POSTGRES_DB: todo
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U user"]
      interval: 5s
      timeout: 5s
      retries: 5
    volumes:
      - pg-data:/var/lib/postgresql/data

  redis:
    image: redis:7-alpine

volumes:
  pg-data:
実行
docker compose up -d

Postgres + Redis + API が 1 コマンドで立ち上がり、API は DB が ready になるまで待ちます。

マイグレーション — デプロイ時の自動化 #

第25章 の Alembic マイグレーションは デプロイ時に 自動で適用される必要があります。

オプション 1 — コンテナ起動時に適用 #

entrypoint.sh
#!/usr/bin/env bash
set -e
alembic upgrade head
exec "$@"
Dockerfile に追加
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh

ENTRYPOINT ["/entrypoint.sh"]
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

シンプルですが 複数のインスタンスが同時に 適用を試みると衝突が起こりえます。小さなトラフィックでのみ。

オプション 2 — CI/CD 段階で別途 #

デプロイパイプラインでマイグレーションを 明示的な段階 に分離。一度だけ実行され、失敗すればデプロイ自体が中断するように。第30章 型チェッカ設定と CI 統合 の GitHub Actions パターンと一緒に扱います。

GitHub Actions 段階の例
- name: Run migrations
  run: |
    uv run alembic upgrade head
  env:
    DATABASE_URL: ${{ secrets.DATABASE_URL }}

- name: Deploy
  run: |
    flyctl deploy --remote-only

大きなプロジェクトはオプション 2 が安全です。

クラウドデプロイ — 軽い選択肢 2 つ #

Railway #

GitHub リポジトリ接続だけで自動デプロイが動く最も軽い選択肢。

  1. railway.app で登録
  2. 「New Project」 → 「Deploy from GitHub」
  3. リポジトリ選択、Postgres / Redis 追加
  4. 環境変数設定 (JWT_SECRETDATABASE_URL など)
  5. 自動デプロイ

railway.json でビルド / 実行コマンドをカスタマイズ可能。

railway.json
{
  "build": {
    "builder": "DOCKERFILE"
  },
  "deploy": {
    "startCommand": "uvicorn app.main:app --host 0.0.0.0 --port $PORT",
    "healthcheckPath": "/health",
    "restartPolicyType": "ON_FAILURE"
  }
}

Fly.io #

複数リージョンへのデプロイが簡単な選択肢。CLI ツール (flyctl) があります。

Fly デプロイ
brew install flyctl                  # macOS
flyctl auth login
flyctl launch                        # 自動で fly.toml 生成
flyctl deploy
flyctl secrets set JWT_SECRET=...
fly.toml — 自動生成後に編集
app = "todo-api"
primary_region = "nrt"

[http_service]
  internal_port = 8000
  force_https = true
  auto_stop_machines = true
  auto_start_machines = true

[checks]
  [checks.health]
    type = "http"
    path = "/health"
    interval = "30s"
    timeout = "5s"

比較:

RailwayFly.io
学習曲線非常になだらかなだらか
料金使用量ベース使用量 + 無料枠
グローバル分散シンプル強い (複数リージョン)
向く場面速いプロトタイプグローバルサービス

プロダクションチェックリスト #

デプロイ前の確認:

  • JWT_SECRET — 64 バイト以上のランダム
  • DEBUG=False または --debug 未使用
  • HTTPS — クラウドプロバイダが自動処理するが強制リダイレクトを確認
  • CORS — allow_origins=["*"] ではなく明示的なドメイン
  • DB パスワード — 推測不能
  • ログレベル — プロダクションは INFO 以上 (第31章 logging と観測性)
  • エラートラッキング — Sentry のようなツール統合 (第31章)
  • ヘルスチェック/health エンドポイント
  • マイグレーション自動化 — デプロイパイプラインに含める
  • バックアップ — DB 自動バックアップ有効
  • モニタリング — レスポンス時間、エラー率ダッシュボード
  • rate limiting — DDoS / abuse 防止

このチェックリストは出発点です。トラフィックと要件に応じて追加されます。

練習問題 #

  1. TestClientAsyncClient + ASGITransport の両方式で GET /health テストを両方書いてください。asyncio_mode = "auto" 設定後、同期 / 非同期テストが 1 つのディレクトリに混在しても正常に実行されるか確認します。
  2. app.dependency_overrides[get_session] = override パターンで in-memory SQLite をテスト DB として使う client フィクスチャを書いてください。POST /todosGET /todos/{id}DELETE フローテストを書いて pytest -v で通過を確認します。
  3. Dockerfile のマルチステージビルドを書き、docker build -t todo-api:0.1 . でビルドします。ビルドしたイメージを docker run して curl http://localhost:8000/health が応答するか確認します。docker images で最終イメージサイズを確認し、slim ベース vs distroless のようなオプションがサイズにどんな差を作るかを直接比較してみます。

一行まとめ: TestClient (同期) / AsyncClient + ASGITransport (非同期)、asyncio_mode = "auto" で統一。app.dependency_overrides で DB / 認証 / 外部呼び出しを隔離。CRUD フローテスト、pytest-httpx 外部モック、カバレッジは 100% ではなく核心経路。Docker マルチステージ + .dockerignore + 非 root、docker-compose でローカルフルスタック。マイグレーションは CI/CD 段階に分離する方が安全。Railway (軽量) vs Fly.io (グローバル)。プロダクションチェックリストはシークレット / HTTPS / CORS / ログ / バックアップ / モニタリング。

次の章 #

次の 第29章 総合実習 — TODO API を完成させる ★新規章で第22 〜 28章のすべてのパターンを 1 つの動作するサービスに編みます。その次の 5部 第30章 型チェッカ設定と CI 統合 から運用領域に入ります。

X