Contents
28 Chapter

Testing and deploy — pytest, Docker, Railway/Fly

FastAPI integration tests with pytest + httpx, isolation via dependency overrides, multi-stage Docker builds, and cloud deployment on Railway/Fly.

The final chapter of Part 4 is testing and deployment. We verify that the API actually works, then build it as a container and deploy it to the cloud.

This chapter pairs with Chapter 30 Type checker setup and CI integration and Chapter 31 Logging and observability. This chapter covers the surface layer of testing and deployment, and the Part 5 chapters cover the deeper operations topics. Chapter 29 Capstone — finishing the TODO API ties the patterns from this chapter into a single service.

pytest + httpx — the standard for FastAPI tests #

FastAPI ships TestClient (built on httpx) as an in-process test client. You can call routes without starting a real HTTP server.

Install (already added in Chapter 22)
uv add --dev pytest pytest-asyncio httpx

First test #

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"}
Run
uv run pytest -v

TestClient gives you a sync interface. Even if the route is async def, you call it directly.

Async tests — httpx.AsyncClient #

To use async fixtures / libraries directly, use 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

Configure async mode in pytest.ini or pyproject.toml.

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

With asyncio_mode = "auto", every async def test_xxx runs as async automatically — the @pytest.mark.asyncio decorator becomes optional.

Dependency overrides — isolate external dependencies #

Using the real DB / external API in tests is slow and flaky. Dependency overrides are the standard FastAPI tool for isolating them.

Extend 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()

The key: app.dependency_overrides[original] = replacement in one line swaps the get_session dependency from Chapter 25 for a test version. Route code doesn’t change a single line. This is the biggest payoff of dependency injection.

Auth bypass #

get_current_user from Chapter 26 Authentication gets the same treatment:

bypass 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 start authenticated without manufacturing a token every time.

Route test patterns #

tests/test_todos.py
import pytest

@pytest.mark.asyncio
async def test_create_todo(authed_client):
    response = await authed_client.post(
        "/todos/",
        json={"title": "Learn pytest"},
    )
    assert response.status_code == 201
    data = response.json()
    assert data["title"] == "Learn 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

The test shape — AAA (Arrange / Act / Assert) is the natural pattern:

  • Arrange — fixtures set things up in advance
  • Act — client.post(...)
  • Assert — verify the response

Flow tests #

A scenario tying multiple requests together:

Flow test
@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

Mocking external calls #

The httpx external calls from Chapter 27 Async and background jobs are best mocked in tests.

Install
uv add --dev pytest-httpx
Mock an external 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 intercepts every httpx request and returns the pre-set response. Without an external dependency, it’s fast and stable.

Coverage #

Measure coverage
uv add --dev coverage pytest-cov
uv run pytest --cov=app --cov-report=term-missing
Example output
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% is not the goal — the goal is whether the important paths are verified. Unlike UI tests, APIs tend to climb the coverage curve well — 80 ~ 90% is a comfortable level.

Integration tests vs unit tests #

KindTarget
Unit testPure functions, logic — test_validate_email(), test_calculate_total()
Integration testRoute + DB + dependencies — test_create_todo()
E2E testReal server + real DB — separate environment

API projects are integration-test heavy. Add unit tests only for complex business logic, and E2E is for smoke tests before deploy.

Docker — a consistent environment #

For deployment, wrap things in a container so they behave the same in any environment.

Dockerfile — multi-stage build #

Dockerfile
# === 1) Build stage — install dependencies with uv ===
FROM python:3.14-slim AS builder

# Install uv
COPY --from=ghcr.io/astral-sh/uv:latest /uv /bin/uv

WORKDIR /app

# Copy dependencies first (cache efficiency)
COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-dev

# Copy code
COPY app/ ./app/

# === 2) Runtime stage — small image ===
FROM python:3.14-slim AS runtime

# Non-root user
RUN useradd --create-home --uid 1000 appuser

WORKDIR /app

# Copy venv and code from builder
COPY --from=builder /app/.venv /app/.venv
COPY --from=builder /app/app /app/app

# Put venv python first on PATH
ENV PATH="/app/.venv/bin:$PATH"

USER appuser

EXPOSE 8000

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

Key patterns:

  • Multi-stage — build tooling drops out of the final image, shrinking it
  • Dependencies first, code later — code-only changes reuse the dependency layer cache
  • Non-root user — security (a root-owned container is dangerous)
  • --frozen — exactly the uv.lock, guaranteed version determinism
  • --no-dev — exclude dev dependencies

.dockerignore #

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

Things that don’t belong in the image. Affects build speed and security.

Build and run #

Local build
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 — local dev environment #

When you want API + DB + Redis up together.

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:
Run
docker compose up -d

Postgres + Redis + API come up with one command, and the API waits until the DB is ready.

Migrations — automated at deploy time #

The Alembic migrations from Chapter 25 should be applied automatically at deploy time.

Option 1 — apply on container start #

entrypoint.sh
#!/usr/bin/env bash
set -e
alembic upgrade head
exec "$@"
Add to 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"]

Simple, but multiple instances applying at the same time can clash. Only safe at small traffic.

Option 2 — separate CI/CD step #

Split migrations into an explicit step in your deploy pipeline. Runs once, and failure stops the deploy itself. Goes together with the GitHub Actions pattern from Chapter 30 Type checker setup and CI integration.

GitHub Actions step example
- name: Run migrations
  run: |
    uv run alembic upgrade head
  env:
    DATABASE_URL: ${{ secrets.DATABASE_URL }}

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

For larger projects, option 2 is the safer choice.

Cloud deployment — two lightweight options #

Railway #

The lightest option — connect your GitHub repo and auto-deploy just works.

  1. Sign up at railway.app
  2. “New Project” → “Deploy from GitHub”
  3. Select the repo, add Postgres / Redis
  4. Set environment variables (JWT_SECRET, DATABASE_URL, etc.)
  5. Auto-deploy

Customize the build / run commands with 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 #

The easy option for multi-region deployment. Comes with a CLI tool (flyctl).

Fly deploy
brew install flyctl                  # macOS
flyctl auth login
flyctl launch                        # auto-creates fly.toml
flyctl deploy
flyctl secrets set JWT_SECRET=...
fly.toml — edit after auto-generation
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"

Comparison:

RailwayFly.io
Learning curveVery flatFlat
PricingUsage-basedUsage + free tier
Global distributionSimpleStrong (multi-region)
Good forFast prototypesGlobal services

Production checklist #

Before deploying, confirm:

  • JWT_SECRET — 64 bytes+ randomness
  • DEBUG=False, or no --debug flag
  • HTTPS — handled by the cloud provider automatically, but verify the force-redirect
  • CORS — explicit domain list, not allow_origins=["*"]
  • DB password — unguessable
  • Log level — INFO or above in production (Chapter 31 Logging and observability)
  • Error tracking — integration with a tool like Sentry (Chapter 31)
  • Health check/health endpoint
  • Migration automation — included in the deploy pipeline
  • Backups — automated DB backups enabled
  • Monitoring — dashboards for response time, error rate
  • Rate limiting — protection against DDoS / abuse

This checklist is a starting point. It grows with traffic and requirements.

Exercises #

  1. Write a GET /health test both with TestClient and with AsyncClient + ASGITransport. With asyncio_mode = "auto" configured, confirm that sync / async tests run cleanly even when mixed in the same directory.
  2. Write a client fixture that uses in-memory SQLite as the test DB via the app.dependency_overrides[get_session] = override pattern. Write a flow test for POST /todosGET /todos/{id}DELETE and confirm it passes with pytest -v.
  3. Write a multi-stage Dockerfile and build it with docker build -t todo-api:0.1 .. docker run the built image and confirm that curl http://localhost:8000/health responds. Check the final image size with docker images, and compare slim base vs distroless to see how the size differs.

In one line: TestClient (sync) / AsyncClient + ASGITransport (async), unified with asyncio_mode = "auto". app.dependency_overrides isolates DB / auth / external calls — the real payoff of dependency injection. Flow tests (one full CRUD cycle), pytest-httpx for external mocks, coverage is not 100% but the critical path. Docker multi-stage + .dockerignore + non-root, docker-compose for the local full stack. Splitting migrations into a CI/CD step is the safe choice. Railway (lightweight) vs Fly.io (global). The production checklist covers secret / HTTPS / CORS / logs / backups / monitoring.

Next chapter #

Next, the ★new Chapter 29 Capstone — finishing the TODO API ties every pattern from Chapters 22 ~ 28 into a single working service. From there, Chapter 30 Type checker setup and CI integration opens Part 5 and the operations territory.

X