Modern Python in Practice #6: Testing and Deployment — pytest, Docker, Railway/Fly

The final post in the practice series — testing and deployment. We cover how to automatically verify that the API really works, how to build a container, and how to ship it to the cloud with a single command.

pytest + httpx — the standard for FastAPI tests #

FastAPI provides TestClient (built on httpx), an in-process test client. You can call routes without spinning up a real HTTP server.

install (already added in #1)
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. Routes can be async def and you still call them directly.

Async tests — httpx.AsyncClient #

To use async fixtures or 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 asynchronously without you needing the @pytest.mark.asyncio decorator.

Dependency overrides — isolating external dependencies #

In tests, hitting a real DB or external API is slow and flaky. Dependency overrides are the standard answer.

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: that one app.dependency_overrides[original] = replacement line swaps the get_session dependency from #3 for a test version. Route code stays untouched. This is the biggest payoff of dependency injection.

Bypassing auth #

The get_current_user from #4 gets the same treatment:

auth bypass
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 run as authenticated without needing to mint tokens each 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

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

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

Flow tests #

A scenario chaining multiple requests:

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 #

It’s good practice to mock the httpx external calls from #5 in tests.

install
uv add --dev pytest-httpx
mocking 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 a pre-arranged response. No external dependency means fast and stable tests.

Coverage #

measuring 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%

The goal isn’t 100% coverage but ensuring the important paths are verified. Unlike UI tests, API coverage rises easily — 80–90% is a reasonable target.

Integration tests vs unit tests #

KindWhere it fits
Unit testsPure functions, logic — test_validate_email(), test_calculate_total()
Integration testsRoutes + DB + dependencies — test_create_todo()
E2E testsReal server + real DB — separate environment

For an API project, integration tests carry the most weight. Unit tests cover only complex business logic; E2E acts as a smoke test before deploy.

Docker — consistent environments #

When deploying, package everything in a container so it behaves the same in any environment.

Dockerfile — multi-stage build #

Dockerfile
# === 1) build stage — install deps 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 deps first (cache friendly)
COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-dev

# Copy source
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

# Prefer venv's python
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 tools don’t end up in the final image, shrinking it
  • Deps first, code later — only code changes don’t bust the deps cache layer
  • Non-root user — security (root containers are dangerous)
  • --frozen — honors uv.lock for deterministic versions
  • --no-dev — excludes 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 development environment #

For bringing up API + DB + Redis 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 — automating at deploy time #

The Alembic migrations from #3 should run at deploy time automatically.

Option 1 — apply at 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 trying to apply at the same time can collide. Only suitable for low traffic.

Option 2 — separate CI/CD step #

In your deploy pipeline, separate migrations into an explicit step. It runs once, and a failure stops the deploy entirely.

GitHub Actions example step
- 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 safer.

Cloud deployment — two lightweight options #

Railway #

The simplest option, with auto-deploy triggered just by connecting a GitHub repo.

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

Customize build/start commands via 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 #

A good option when multi-region deployment matters. It comes with a CLI (flyctl).

Fly deploy
brew install flyctl                  # macOS
flyctl auth login
flyctl launch                        # auto-generates fly.toml
flyctl deploy
flyctl secrets set JWT_SECRET=...
fly.toml — auto-generated, then edited
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)
Best fitQuick prototypeGlobal service

Production checklist #

Before deploying:

  • JWT_SECRET — 64+ bytes of randomness
  • DEBUG=False or no --debug
  • HTTPS — your cloud provider usually handles it; verify forced redirect
  • CORS — explicit domains, not allow_origins=["*"]
  • DB password — not guessable
  • Log level — INFO or higher in production
  • Error tracking — integrate something like Sentry
  • Health check/health endpoint
  • Migration automation — included in the deploy pipeline
  • Backups — DB auto-backups enabled
  • Monitoring — response time and error rate dashboards
  • Rate limiting — DDoS / abuse protection

This is a starting point. The list grows with traffic and requirements.

Recap + series retrospective #

What this post nailed down:

  • pytest + httpxTestClient (sync), AsyncClient + ASGITransport (async)
  • asyncio_mode = "auto" — auto-detects every async test
  • app.dependency_overrides — isolates DB/auth/external calls, the real value of dependency injection
  • Flow tests — Create → Get → Update → Delete
  • pytest-httpx mocks external calls
  • Coverage — not 100%, just important paths secured
  • Docker multi-stage — uv for deps, non-root, .dockerignore
  • docker-compose for the local full stack (API + DB + Redis)
  • Migrations are safer as a deploy pipeline step
  • Railway (lightest) vs Fly.io (global)
  • Production checklist — secrets, HTTPS, CORS, backups, monitoring

Series retrospective #

Across 6 posts, the Modern Python in Practice — FastAPI flow is complete.

#Covered
1FastAPI setup, automatic OpenAPI docs
2APIRouter, Pydantic v2, dependency injection
3SQLAlchemy 2.x async, Alembic
4argon2 + JWT, OAuth2 password flow
5async/await, BackgroundTasks, external queues, lifespan
6pytest + httpx, Docker, Railway/Fly

Modern Python track retrospective (4 series / 27 posts) #

SeriesPostsCore
Modern Python Basics7uv, type hints, match-case, pyproject
Modern Python Intermediate7dataclass, Protocol, with, generator, decorator, async
Modern Python Advanced7dunder, descriptor, metaclass, asyncio deep dive, GIL, advanced typing, performance
Modern Python in Practice6full-stack FastAPI

This track fills the 3.14 + modern toolchain + type-hints-first gap that the old Python lessons (2017–2018) couldn’t cover. The new track sits in that slot, the old track stays as is, and you can see Python from both eras side by side.

Where to go next:

  • FastAPI deeper — serious WebSocket, microservice patterns, distributed tracing
  • Data — pandas, polars, data pipelines
  • AI/LLM — RAG and agents with the Vercel AI SDK / Anthropic SDK
  • Systems programming — extension modules with Cython, PyO3

Each direction deserves its own track.

X