Testing a FastAPI app
The framework is unusually testable, and the reason is dependency injection:
every external thing a handler touches arrives through Depends, so every
external thing has a seam. Most of the work is deciding what to replace.
TestClient is synchronous, and that is fine
from fastapi.testclient import TestClient
client = TestClient(app)
def test_creates_order():
r = client.post("/orders", json={"sku": "ABC", "qty": 2})
assert r.status_code == 201
assert r.json()["sku"] == "ABC"TestClient wraps httpx and runs the ASGI app in a worker thread, so your
tests stay plain synchronous pytest even though the handlers are async. It
runs lifespan when used as a context manager, which is the detail people trip
on:
# lifespan runs: pools built, then closed
with TestClient(app) as client:
...Without the with, lifespan never fires and anything set up there —
app.state.http, a connection pool — is missing. The symptom is an
AttributeError on app.state that looks nothing like the cause.
When you need a real async client
TestClient is enough until the test itself must be async — an async fixture,
a concurrency assertion, or exercising an async context manager:
from httpx import AsyncClient, ASGITransport
@pytest.fixture
async def client():
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as c:
yield c
async def test_concurrent(client):
rs = await asyncio.gather(*(client.get("/slow") for _ in range(10)))
assert all(r.status_code == 200 for r in rs)ASGITransport calls the app in-process — no socket, no port, no server. That
is what makes it fast enough to run per test.
dependency_overrides is the seam
app.dependency_overrides[get_session] = lambda: test_session
app.dependency_overrides[current_user] = lambda: User(id=1, role="admin")
app.dependency_overrides[billing] = lambda: FakeBilling()The whole real request path still runs — routing, validation, middleware, serialisation, the response model — and only the leaves are swapped. That is the difference between this and calling the handler function directly, which tests none of it.
Two rules. Override by the function object, not by name — the key is identity, so importing the same symbol matters. And clear between tests, or one test’s fake leaks into the next:
@pytest.fixture(autouse=True)
def _reset_overrides():
yield
app.dependency_overrides.clear()The database: real, in a transaction
Mocking the session tests your mock. Run a real database and roll back:
@pytest.fixture(scope="session")
async def engine():
async with PostgresContainer("postgres:18") as pg:
e = create_async_engine(pg.get_connection_url())
async with e.begin() as c:
await c.run_sync(Base.metadata.create_all)
yield e
@pytest.fixture
async def test_session(engine):
async with engine.connect() as conn:
tx = await conn.begin()
async with AsyncSession(bind=conn) as s:
yield s
# isolation, without re-creating anything
await tx.rollback()Session-scoped container for the cost, function-scoped transaction for the isolation. Same shape as Fixtures, parametrize and scope, and the reason a suite is fast or a suite is twenty minutes.
Gotcha: the override must yield that session. If
get_sessionopens its own from the engine, the handler writes in a different transaction from the one your test rolls back, and rows leak between tests while every assertion still passes.
What to assert
- Status and shape, not internals.
assert r.status_code == 201and the parsed body — see the behaviour-versus-implementation argument in Test Strategy and the Test Pyramid. - The failures. 422 on bad input, 404 on missing, 409 on conflict. The happy path rarely regresses.
- Authorisation, per role. A parametrised test over roles catches the endpoint someone forgot to guard.
- The contract. A response containing a field your
response_modeldoes not declare is a leak; the model prevents it, and a test proves the model is applied.
Outbound HTTP is mocked at the transport with respx, never by patching your
own client class — Mocking external APIs.
Related
Interview angle 6
- “How do you test a FastAPI app?” -
TestClientfor most things: it runs the ASGI app in a worker thread so tests stay synchronous, and the whole real request path executes. Swap externals withdependency_overridesrather than calling handler functions directly. - “What does
TestClientdo that people miss?” - lifespan only runs when it is used as a context manager. Withoutwith TestClient(app) as client:anything built in lifespan is absent, and the failure looks like an unrelatedAttributeErroronapp.state. - “When do you need
httpx.AsyncClient?” - when the test itself must be async: async fixtures, concurrency assertions, async context managers.ASGITransportcalls the app in-process, so there is no socket and it stays fast. - “How do you test against a database?” - a real one. A session-scoped container for the cost and a function-scoped transaction rolled back per test for isolation. Mocking the session tests the mock, and the queries are the part most likely to be wrong.
- “What’s the subtle bug in overriding the session?” - the override must yield the same session the test rolls back. If
get_sessionopens its own from the engine, the handler commits in a different transaction and rows leak between tests while assertions still pass. - “What do you assert?” - status and parsed body, the failure paths, and authorisation per role. Not internal calls: a test that asserts a private method ran breaks on every refactor and proves nothing about behaviour.