references/testing.md
Testing a FastAPI backend
The goal is a suite you trust enough to deploy on green, that runs in Docker so it behaves the same on a laptop and in CI.
The shape of the suite
Inverted from the classic pyramid, deliberately. An API service is mostly glue between HTTP and a database, and glue is exactly what unit tests with mocks fail to verify.
| Type | Share | What it covers |
|---|---|---|
| Integration (API level) | ~70% | Real app, real database, HTTP in and JSON out. The default. |
| Unit (service level) | ~25% | Pure business rules with interesting branches: pricing, state machines, permissions, money. |
| Contract / smoke | ~5% | The deployed image boots, /readyz passes, OpenAPI is valid. |
A mocked-repository test for a CRUD endpoint asserts that your mock returns what you told it to. It passes when the query is wrong, the index is missing, the migration was never applied, and the serializer drops a field.
The client: ASGITransport
httpx.AsyncClient over ASGITransport runs the real application in-process. No socket, no port, no server lifecycle, no flake, and it still goes through middleware, dependencies, validation, serialization, and exception handlers.
# tests/conftest.py
import pytest_asyncio
from httpx import ASGITransport, AsyncClient
from main import app
@pytest_asyncio.fixture
async def client():
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
yield c
Set asyncio_mode = auto in pytest.ini so async tests do not each need a decorator.
TestClient (sync, from Starlette) still has a place: it runs the lifespan context, so use it for the one test that asserts startup and shutdown behave. For everything else the async client is faster and matches how the app runs.
Isolation
Every test starts from a known state. Two workable strategies:
Drop and reseed per test. Simple, obviously correct, slower.
@pytest_asyncio.fixture(autouse=True)
async def setup_db():
await connect_db()
db = get_db()
for name in await db.list_collection_names():
await db.drop_collection(name)
await create_indexes()
await seed_all()
yield
await close_db()
Transaction per test, rolled back. For SQL, wrap each test in a transaction and roll it back at teardown. Much faster, but code under test must not commit or open its own connection.
Whichever you pick, the invariant is the same: running one test alone and running the whole file must give the same result. If they diverge, tests are sharing state and you cannot trust a green run.
Seeding needs to be deterministic. A seed that uses random or datetime.now() produces tests that fail once a month for reasons nobody can reproduce.
Fixtures for identity
Auth setup repeated in thirty tests is thirty places to update when the login flow changes. Make roles into fixtures:
@pytest_asyncio.fixture
async def auth_headers(client):
r = await client.post("/v1/auth/login", json={...})
token = r.json()["accessToken"]
return {"Authorization": f"Bearer {token}"}
@pytest_asyncio.fixture
async def admin_headers(client): ...
Assert the status of each step inside the fixture. When login breaks, you want one clear failure in the fixture, not forty confusing 401s in unrelated tests.
Factories over fixtures for data
Fixtures are good for context (a client, a logged-in user). They are poor for data, because every test wants a slightly different object and you end up with vehicle_with_no_price_and_two_bids.
def make_vehicle(**overrides):
return {"make": "Toyota", "model": "Hilux", "year": 2021,
"priceMinor": 1_500_000, "status": "draft", **overrides}
async def test_cannot_publish_without_price(client, admin_headers):
v = make_vehicle(priceMinor=None)
...
The test then shows only what it cares about, which is also its documentation.
What to assert
For each endpoint, in rough priority order:
- Happy path: status code, and the response shape (field names, types,
nesting), not just one field.
- Authorization: anonymous gets 401, wrong role gets 403. Write this for
every protected endpoint. It is the most skipped test and the most exploited gap.
- Validation: bad payload gives 422 and names the offending field.
- Not found and conflict: 404 for a missing id, 409 for a duplicate.
- State transitions: illegal moves are rejected. A settled auction cannot
accept a bid.
- Persistence: read back after write. A create that returns 201 but stores
nothing is a real bug that response-only assertions miss.
Test the response envelope, not just the payload. If clients depend on {"data": ..., "meta": ...}, assert that structure explicitly, because the day it changes should be a failing test rather than a support ticket.
Mock only at the boundary
Mock things you do not own and cannot run: payment providers, SMS, email, third-party HTTP. Use respx or an httpx.MockTransport so the mock sits at the transport layer and your real client code still runs.
import respx, httpx
@respx.mock
async def test_payment_failure_marks_order(client, admin_headers):
respx.post("https://payments.example/charge").mock(
return_value=httpx.Response(402, json={"error": "insufficient_funds"})
)
...
Never mock your own service or repository. If a test needs that to be manageable, the coupling it is working around is the actual finding.
Determinism
- Freeze time with
freezegunor an injected clock. Anything asserting
expiry, scheduling, or "ends in 2 hours" is otherwise a future flake.
- Seed randomness, or inject the id generator.
- Never
sleepto wait for background work. Expose a way to run the job
synchronously in tests and call it.
Time and randomness are the two most common causes of a suite that fails once a week in CI and passes locally every time.
Running in Docker
Tests should run against the same database engine and version as production. A suite that passes on SQLite and deploys to Postgres is testing a different system.
# docker-compose.test.yml
name: myapp-test
services:
db-test:
image: mongo:7
tmpfs:
- /data/db # RAM-backed: large speedup, and nothing to clean up
ulimits:
nofile: { soft: 65536, hard: 65536 }
healthcheck:
test: ["CMD", "mongosh", "--eval", "db.adminCommand('ping')"]
interval: 5s
timeout: 3s
retries: 10
api-test:
build: .
env_file: [.env.test]
volumes:
- .:/app # live code, so a re-run does not rebuild
depends_on:
db-test:
condition: service_healthy
command: pytest --maxfail=3 --disable-warnings -q
Three details that matter more than they look:
tmpfsfor the data directory. Test databases do not need to survive, and
RAM-backed storage is a large speedup on write-heavy suites.
condition: service_healthy. Without it the test container races the
database and fails on connection refused roughly one run in five.
- Raise
nofile. Engines open descriptors per collection and per index. A
seeded schema plus the default 1024 limit produces "too many open files" failures that look like flakes but are not.
Run it the same way locally and in CI:
docker compose -f docker-compose.test.yml up --build --abort-on-container-exit --exit-code-from api-test
--abort-on-container-exit with --exit-code-from is what makes the pipeline fail when the tests fail. Without them the compose command exits 0 and the pipeline goes green over a red suite.
Testcontainers, as an alternative
testcontainers starts the database from inside the test process instead of from compose, which is convenient for a library or when a single test file needs its own engine. The tradeoff is that it needs a Docker socket in CI and gives you less obvious control over startup ordering. For a service that already ships a compose file, compose is the simpler answer.
Speed
A slow suite gets skipped, which makes it worthless no matter how good it is.
- Parallelize with
pytest-xdist, giving each worker its own database
(test_db_{worker_id}). Shared state across workers is the usual reason parallel runs fail.
- Scope expensive fixtures wider than function scope when they are genuinely
read-only.
--maxfailin CI to fail fast on a broken build.- Watch for a suite dominated by password hashing. Use low bcrypt rounds in the
test config; it is the single most common hidden cost.
Dependency overrides
app.dependency_overrides is the seam FastAPI gives you for testing. It works well for swapping the clock, a feature flag, or an external client. Design dependencies so that is possible:
app.dependency_overrides[get_settings] = lambda: Settings(rate_limit_enabled=False)
Always clear overrides in teardown, or they leak into the next test.
Resist using it to override the database. That converts an integration test into a mocked one and gives up the thing that made it valuable.