モダンPython実践 #6 テストとデプロイ — pytest、Docker、Railway/Fly

実践シリーズの最後 — テストとデプロイ です。作った API が本当に動作するかを自動で検証し、1 つのコマンドでコンテナをビルドしてクラウドに上げる流れまで一カ所に整理します。

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

FastAPI は TestClient (httpx ベース) という in-process のテストクライアントを提供します。実際の HTTP サーバーを起動しなくてもルートを呼べます。

インストール (#1 で既に追加済み)
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[元] = 代替 の一行が #3 で見た get_session 依存性をテスト版に差し替えます。ルートのコードは 1 行も変わりません。これが依存性注入の最大の効用 です。

認証のバイパス #

#4get_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

外部呼び出しのモック #

#5 で見た 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 を待ちます。

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

#3 の 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 ステージで別途 #

デプロイパイプラインでマイグレーションを 明示的なステージ として分離。1 度だけ実行され、失敗するとデプロイ自体が中断するように。

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 以上
  • エラー追跡 — Sentry のようなツール統合
  • ヘルスチェック/health エンドポイント
  • マイグレーション自動化 — デプロイパイプラインに含める
  • バックアップ — DB の自動バックアップを有効化
  • モニタリング — レスポンス時間、エラー率のダッシュボード
  • rate limiting — DDoS / abuse の防止

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

まとめ + シリーズの振り返り #

今回つかんだもの:

  • pytest + httpxTestClient(同期)、AsyncClient + ASGITransport(非同期)
  • asyncio_mode = "auto" — すべての async テストを自動認識
  • app.dependency_overrides — DB / 認証 / 外部呼び出しの分離、依存性注入の真価
  • 流れのテスト — Create → Get → Update → Delete
  • pytest-httpx で外部呼び出しのモック
  • カバレッジ — 100% ではなく 重要な経路 を保証
  • Docker のマルチステージ — uv で依存関係、非 root、.dockerignore
  • docker-compose でローカルフルスタック (API + DB + Redis)
  • マイグレーションは デプロイパイプラインのステージ に分離が安全
  • Railway (最も軽い) vs Fly.io (グローバル)
  • プロダクションチェックリスト — シークレット、HTTPS、CORS、バックアップ、モニタリング

シリーズの振り返り #

6 編を経て モダン Python 実践 — FastAPI の流れが完成しました。

#扱ったもの
1FastAPI セットアップ、OpenAPI 自動ドキュメント
2APIRouter、Pydantic v2、依存性注入
3SQLAlchemy 2.x async、Alembic
4argon2 + JWT、OAuth2 パスワードフロー
5async/await、BackgroundTasks、外部キュー、lifespan
6pytest + httpx、Docker、Railway/Fly

モダン Python トラックの振り返り (4 シリーズ / 27 編) #

シリーズ編数核心
モダン Python 基礎7uv、型ヒント、match-case、pyproject
モダン Python 中級7dataclass、Protocol、with、generator、decorator、async
モダン Python 上級7dunder、descriptor、metaclass、asyncio の深さ、GIL、typing 上級、性能
モダン Python 実践6FastAPI フルスタック

このトラックで 旧 Python 講座 (2017~2018) が扱えなかった 3.14 + モダンツールチェーン + 型ヒント優先 の観点が埋まりました。新しいトラックがその位置にあり、旧トラックはそのまま残って、2 つの時点の Python を見比べられるようになります。

次の学習方向:

  • FastAPI の深さ — WebSocket 本格、マイクロサービスのパターン、分散トレーシング
  • データ — pandas、polars、データパイプライン
  • AI/LLM — Vercel AI SDK / Anthropic SDK で RAG、エージェント
  • システムプログラミング — Cython、PyO3 で拡張モジュール

それぞれの方向は別トラックで扱う価値のある領域です。

X