テストとデプロイ — 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 サーバーを立ち上げずにルートを呼び出せます。
uv add --dev pytest pytest-asyncio httpx最初のテスト #
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 -vTestClient は同期インターフェースを提供します。ルートが async def でもそのまま呼び出し。
非同期テスト — httpx.AsyncClient
#
非同期フィクスチャ / ライブラリを直接使うなら AsyncClient。
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 cimport pytest
@pytest.mark.asyncio
async def test_health(client):
response = await client.get("/health")
assert response.status_code == 200pytest.ini または pyproject.toml で非同期モードを設定。
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]asyncio_mode = "auto" にすると、すべての async def test_xxx が自動で非同期として実行されます — @pytest.mark.asyncio デコレータは省略可能。
依存性オーバーライド — 外部依存性の隔離 #
テスト時に本物の DB / 外部 API を使うと遅く、変動性があります。依存性オーバーライド が標準解です。
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 も同じ方式で:
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テストが毎回トークンを作る必要なく、認証された状態で開始。
ルートテストパターン #
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-httpximport 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-missingName 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 — マルチステージビルド #
# === 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 権限コンテナは危険)
--frozen—uv.lockをそのまま、バージョン決定性保証--no-dev— 開発依存性除外
.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.1docker-compose — ローカル開発環境 #
API + DB + Redis を一緒に立ち上げるとき。
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 -dPostgres + Redis + API が 1 コマンドで立ち上がり、API は DB が ready になるまで待ちます。
マイグレーション — デプロイ時の自動化 #
第25章 の Alembic マイグレーションは デプロイ時に 自動で適用される必要があります。
オプション 1 — コンテナ起動時に適用 #
#!/usr/bin/env bash
set -e
alembic upgrade head
exec "$@"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 パターンと一緒に扱います。
- 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 リポジトリ接続だけで自動デプロイが動く最も軽い選択肢。
- railway.app で登録
- 「New Project」 → 「Deploy from GitHub」
- リポジトリ選択、Postgres / Redis 追加
- 環境変数設定 (
JWT_SECRET、DATABASE_URLなど) - 自動デプロイ
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) があります。
brew install flyctl # macOS
flyctl auth login
flyctl launch # 自動で fly.toml 生成
flyctl deploy
flyctl secrets set JWT_SECRET=...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"比較:
| Railway | Fly.io | |
|---|---|---|
| 学習曲線 | 非常になだらか | なだらか |
| 料金 | 使用量ベース | 使用量 + 無料枠 |
| グローバル分散 | シンプル | 強い (複数リージョン) |
| 向く場面 | 速いプロトタイプ | グローバルサービス |
プロダクションチェックリスト #
デプロイ前の確認:
-
JWT_SECRET— 64 バイト以上のランダム -
DEBUG=Falseまたは--debug未使用 - HTTPS — クラウドプロバイダが自動処理するが強制リダイレクトを確認
- CORS —
allow_origins=["*"]ではなく明示的なドメイン - DB パスワード — 推測不能
- ログレベル — プロダクションは INFO 以上 (第31章 logging と観測性)
- エラートラッキング — Sentry のようなツール統合 (第31章)
- ヘルスチェック —
/healthエンドポイント - マイグレーション自動化 — デプロイパイプラインに含める
- バックアップ — DB 自動バックアップ有効
- モニタリング — レスポンス時間、エラー率ダッシュボード
- rate limiting — DDoS / abuse 防止
このチェックリストは出発点です。トラフィックと要件に応じて追加されます。
練習問題 #
TestClientとAsyncClient + ASGITransportの両方式でGET /healthテストを両方書いてください。asyncio_mode = "auto"設定後、同期 / 非同期テストが 1 つのディレクトリに混在しても正常に実行されるか確認します。app.dependency_overrides[get_session] = overrideパターンで in-memory SQLite をテスト DB として使うclientフィクスチャを書いてください。POST /todos→GET /todos/{id}→DELETEフローテストを書いてpytest -vで通過を確認します。- 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 統合 から運用領域に入ります。