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.
uv add --dev pytest pytest-asyncio httpxFirst test #
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 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.
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 == 200Configure async mode in pytest.ini or 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.
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:
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 clientTests start authenticated without manufacturing a token every time.
Route test patterns #
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 == 404The 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:
@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 == 404Mocking external calls #
The httpx external calls from Chapter 27 Async and background jobs are best mocked in tests.
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 intercepts every httpx request and returns the pre-set response. Without an external dependency, it’s fast and stable.
Coverage #
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% 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 #
| Kind | Target |
|---|---|
| Unit test | Pure functions, logic — test_validate_email(), test_calculate_total() |
| Integration test | Route + DB + dependencies — test_create_todo() |
| E2E test | Real 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 #
# === 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 theuv.lock, guaranteed version determinism--no-dev— exclude dev dependencies
.dockerignore #
.venv/
__pycache__/
*.pyc
.pytest_cache/
.git/
.github/
tests/
.env
*.mdThings that don’t belong in the image. Affects build speed and security.
Build and run #
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 — local dev environment #
When you want API + DB + Redis up together.
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 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 #
#!/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"]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.
- name: Run migrations
run: |
uv run alembic upgrade head
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
- name: Deploy
run: |
flyctl deploy --remote-onlyFor 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.
- Sign up at railway.app
- “New Project” → “Deploy from GitHub”
- Select the repo, add Postgres / Redis
- Set environment variables (
JWT_SECRET,DATABASE_URL, etc.) - Auto-deploy
Customize the build / run commands with 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).
brew install flyctl # macOS
flyctl auth login
flyctl launch # auto-creates 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"Comparison:
| Railway | Fly.io | |
|---|---|---|
| Learning curve | Very flat | Flat |
| Pricing | Usage-based | Usage + free tier |
| Global distribution | Simple | Strong (multi-region) |
| Good for | Fast prototypes | Global services |
Production checklist #
Before deploying, confirm:
-
JWT_SECRET— 64 bytes+ randomness -
DEBUG=False, or no--debugflag - 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 —
/healthendpoint - 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 #
- Write a
GET /healthtest both withTestClientand withAsyncClient + ASGITransport. Withasyncio_mode = "auto"configured, confirm that sync / async tests run cleanly even when mixed in the same directory. - Write a
clientfixture that uses in-memory SQLite as the test DB via theapp.dependency_overrides[get_session] = overridepattern. Write a flow test forPOST /todos→GET /todos/{id}→DELETEand confirm it passes withpytest -v. - Write a multi-stage Dockerfile and build it with
docker build -t todo-api:0.1 ..docker runthe built image and confirm thatcurl http://localhost:8000/healthresponds. Check the final image size withdocker images, and compare slim base vs distroless to see how the size differs.
In one line:
TestClient(sync) /AsyncClient+ASGITransport(async), unified withasyncio_mode = "auto".app.dependency_overridesisolates DB / auth / external calls — the real payoff of dependency injection. Flow tests (one full CRUD cycle),pytest-httpxfor 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.