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.
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. Routes can be async def and you still call them directly.
Async tests — httpx.AsyncClient
#
To use async fixtures or 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 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.
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:
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 run as authenticated without needing to mint tokens each 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 == 404Test 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:
@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 #
It’s good practice to mock the httpx external calls from #5 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 a pre-arranged response. No external dependency means fast and stable tests.
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%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 #
| Kind | Where it fits |
|---|---|
| Unit tests | Pure functions, logic — test_validate_email(), test_calculate_total() |
| Integration tests | Routes + DB + dependencies — test_create_todo() |
| E2E tests | Real 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 #
# === 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— honorsuv.lockfor deterministic versions--no-dev— excludes 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 development environment #
For bringing up API + DB + Redis 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 — automating at deploy time #
The Alembic migrations from #3 should run at deploy time automatically.
Option 1 — apply at 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 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.
- 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 safer.
Cloud deployment — two lightweight options #
Railway #
The simplest option, with auto-deploy triggered just by connecting a GitHub repo.
- Sign up at railway.app
- “New Project” → “Deploy from GitHub”
- Pick the repo, add Postgres/Redis
- Configure environment variables (
JWT_SECRET,DATABASE_URL, etc.) - Auto deploy
Customize build/start commands via 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).
brew install flyctl # macOS
flyctl auth login
flyctl launch # auto-generates 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) |
| Best fit | Quick prototype | Global service |
Production checklist #
Before deploying:
-
JWT_SECRET— 64+ bytes of randomness -
DEBUG=Falseor 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 —
/healthendpoint - 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 + httpx —
TestClient(sync),AsyncClient+ASGITransport(async) asyncio_mode = "auto"— auto-detects every async testapp.dependency_overrides— isolates DB/auth/external calls, the real value of dependency injection- Flow tests — Create → Get → Update → Delete
pytest-httpxmocks 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 |
|---|---|
| 1 | FastAPI setup, automatic OpenAPI docs |
| 2 | APIRouter, Pydantic v2, dependency injection |
| 3 | SQLAlchemy 2.x async, Alembic |
| 4 | argon2 + JWT, OAuth2 password flow |
| 5 | async/await, BackgroundTasks, external queues, lifespan |
| 6 | pytest + httpx, Docker, Railway/Fly |
Modern Python track retrospective (4 series / 27 posts) #
| Series | Posts | Core |
|---|---|---|
| Modern Python Basics | 7 | uv, type hints, match-case, pyproject |
| Modern Python Intermediate | 7 | dataclass, Protocol, with, generator, decorator, async |
| Modern Python Advanced | 7 | dunder, descriptor, metaclass, asyncio deep dive, GIL, advanced typing, performance |
| Modern Python in Practice | 6 | full-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.