モダンPython実践 #6 テストとデプロイ — pytest、Docker、Railway/Fly
実践シリーズの最後 — テストとデプロイ です。作った API が本当に動作するかを自動で検証し、1 つのコマンドでコンテナをビルドしてクラウドに上げる流れまで一カ所に整理します。
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[元] = 代替 の一行が #3 で見た get_session 依存性をテスト版に差し替えます。ルートのコードは 1 行も変わりません。これが依存性注入の最大の効用 です。
認証のバイパス #
#4 の 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外部呼び出しのモック #
#5 で見た 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 を待ちます。
マイグレーション — デプロイ時点で自動化 #
#3 の 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 ステージで別途 #
デプロイパイプラインでマイグレーションを 明示的なステージ として分離。1 度だけ実行され、失敗するとデプロイ自体が中断するように。
- 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 以上
- エラー追跡 — Sentry のようなツール統合
- ヘルスチェック —
/healthエンドポイント - マイグレーション自動化 — デプロイパイプラインに含める
- バックアップ — DB の自動バックアップを有効化
- モニタリング — レスポンス時間、エラー率のダッシュボード
- rate limiting — DDoS / abuse の防止
このチェックリストは出発点です。トラフィックと要件に応じて追加されます。
まとめ + シリーズの振り返り #
今回つかんだもの:
- pytest + httpx —
TestClient(同期)、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 の流れが完成しました。
| # | 扱ったもの |
|---|---|
| 1 | FastAPI セットアップ、OpenAPI 自動ドキュメント |
| 2 | APIRouter、Pydantic v2、依存性注入 |
| 3 | SQLAlchemy 2.x async、Alembic |
| 4 | argon2 + JWT、OAuth2 パスワードフロー |
| 5 | async/await、BackgroundTasks、外部キュー、lifespan |
| 6 | pytest + httpx、Docker、Railway/Fly |
モダン Python トラックの振り返り (4 シリーズ / 27 編) #
| シリーズ | 編数 | 核心 |
|---|---|---|
| モダン Python 基礎 | 7 | uv、型ヒント、match-case、pyproject |
| モダン Python 中級 | 7 | dataclass、Protocol、with、generator、decorator、async |
| モダン Python 上級 | 7 | dunder、descriptor、metaclass、asyncio の深さ、GIL、typing 上級、性能 |
| モダン Python 実践 | 6 | FastAPI フルスタック |
このトラックで 旧 Python 講座 (2017~2018) が扱えなかった 3.14 + モダンツールチェーン + 型ヒント優先 の観点が埋まりました。新しいトラックがその位置にあり、旧トラックはそのまま残って、2 つの時点の Python を見比べられるようになります。
次の学習方向:
- FastAPI の深さ — WebSocket 本格、マイクロサービスのパターン、分散トレーシング
- データ — pandas、polars、データパイプライン
- AI/LLM — Vercel AI SDK / Anthropic SDK で RAG、エージェント
- システムプログラミング — Cython、PyO3 で拡張モジュール
それぞれの方向は別トラックで扱う価値のある領域です。