Testing FastAPI Applications
Testing a FastAPI application well means testing it through the framework. Routing, dependency resolution, request validation and response serialization are where most production bugs live, and none of them run when you call a path operation function directly. This guide covers the client choice, the fixture patterns, and the dividing line between tests that need the app and tests that do not.
This is part of Async, Background Tasks and Observability. It leans heavily on Async Correctness and Concurrency, because the difference between the two test clients is entirely a question of which event loop runs your app.
Prerequisites
- FastAPI 0.139.2, httpx 0.28.1, pytest and pytest-asyncio, on Python 3.12.
- An app whose dependencies are declared with
Depends, since that is the seam every substitution below uses. - A
pytest.ini(or equivalent) withasyncio_mode = auto, otherwise pytest collectsasync deftests and refuses to run them.
Core Mechanics: There Is No Server
Both supported clients drive your ASGI application in-process. httpx.ASGITransport implements httpx's transport interface by building an ASGI scope, calling app(scope, receive, send) and assembling the response from the messages it sends back. No socket is opened, no port is bound, nothing is serialized over TCP.
This is what makes these tests both fast and honest. Fast, because a request is a function call. Honest, because everything above the transport is real: your middleware stack runs, routing runs, dependencies resolve, Pydantic validates the request and serializes the response. The only fidelity you give up is at the protocol edge — HTTP/1.1 parsing, connection handling, real timeouts — and that belongs in a smoke test against a deployed instance, not in your unit suite.
TestClient is built on the same idea with one addition: it wraps the app in an AnyIO blocking portal. The portal starts an event loop on a separate thread and gives the calling thread a synchronous handle onto it. That is how client.get("/x") can be a plain function call with no await — the coroutine really does run, just not on your thread.
Everything in the rest of this guide follows from that one structural difference, and it is measurable:
$ GET /pytest/loops
200 OK
..
same_loop = False
testclient_thread = asyncio-portal-<id>
asyncclient_thread = MainThread
contextvar_seen_by_testclient = 'set-by-the-test'
contextvar_seen_by_asyncclient = 'set-by-the-test'
.
3 passed in 0.00s
Those lines come from a real suite where the endpoint reports the id of the loop and the name of the thread it is running on. Under TestClient the app ran on a thread named asyncio-portal-<id> on a different loop from the test. Under AsyncClient it ran on MainThread, on the test's own loop. Usefully, context variables set by the test were visible in both — AnyIO copies the caller's context into the portal — so contextvars-based request context is not a reason to avoid TestClient.
Choosing a Client
from fastapi.testclient import TestClient
from myapp import app
def test_health():
with TestClient(app) as client:
assert client.get("/health").json() == {"status": "ok"}
from httpx import ASGITransport, AsyncClient
from myapp import app
async def test_health():
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
assert (await client.get("/health")).json() == {"status": "ok"}
Both are correct. The decision rule is short:
| Situation | Client | Why |
|---|---|---|
| Simple synchronous test, no async fixtures | TestClient | Least ceremony; no plugin needed |
| Test depends on an async fixture (DB session, cache, client) | AsyncClient | Fixture and app share one event loop |
| You need concurrent requests inside one test | AsyncClient | TestClient calls are blocking and serialise |
| You need startup state with no extra code | TestClient in a with block | Runs lifespan for you |
| You are testing WebSockets | TestClient | It ships a WebSocket test session |
| The suite is already async end to end | AsyncClient | One concurrency model throughout |
The lifespan row is the one that surprises people, and it is worth seeing directly:
$ GET /pytest/lifespan
200 OK
TestClient inside `with` -> lifespan_ran True
.TestClient without `with` -> lifespan_ran False
.AsyncClient + ASGITransport -> lifespan_ran False
.AsyncClient + lifespan_context -> lifespan_ran True
.
4 passed in 0.00s
Four real assertions against an app whose lifespan sets a flag. TestClient runs the lifespan only inside a with block — constructing one and calling it directly silently skips startup. ASGITransport never runs it, because it only handles the http scope. When you need startup state under AsyncClient, drive it yourself:
async def test_with_startup_state():
async with app.router.lifespan_context(app):
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
assert (await client.get("/lifespan")).json() == {"lifespan_ran": True}
The full mechanism, including what each client does to the loop your test is running on, is covered in TestClient vs httpx AsyncClient.
Dependency Overrides: the Main Seam
app.dependency_overrides is a dict keyed by the original dependency callable. FastAPI consults it while resolving the dependency graph, so a substitution applies to every route that declares that dependency, however deeply nested.
The behaviour that matters most is what happens to sub-dependencies, and it is easy to get backwards. Consider an app where get_current_user depends on get_api_key, and get_api_key rejects any request without the right header:
async def test_without_an_override_the_real_dependency_rejects():
response = await call("/me")
assert response.status_code == 401
async def test_overriding_the_outer_dependency_skips_the_inner_one():
"""get_api_key never runs, so no header is needed at all."""
app.dependency_overrides[get_current_user] = lambda: {"id": 99, "name": "fake", "via": "override"}
response = await call("/me")
assert response.json()["user"]["id"] == 99
async def test_overriding_the_inner_dependency_still_runs_the_outer():
"""get_api_key is faked; get_current_user still executes and wraps the fake value."""
app.dependency_overrides[get_api_key] = lambda: "test-key"
response = await call("/me")
assert response.json()["user"] == {"id": 1, "name": "real-user", "via": "test-key"}
Real output from that suite:
$ GET /pytest/overrides
200 OK
no header, no override -> 401 {'detail': 'bad api key'}
.real header -> 200 {'user': {'id': 1, 'name': 'real-user', 'via': 'live-secret'}}
.override get_current_user-> 200 {'user': {'id': 99, 'name': 'fake', 'via': 'override'}}
.override get_api_key -> 200 {'user': {'id': 1, 'name': 'real-user', 'via': 'test-key'}}
.after clear() -> 401 {'detail': 'bad api key'}
.
5 passed in 0.00s
Read the third and fourth lines together. Overriding get_current_user produced id: 99 — the real get_current_user never ran, so neither did get_api_key, and the header requirement disappeared entirely. Overriding get_api_key instead produced id: 1 with via: 'test-key': the real get_current_user still executed, and simply received a fake key. Both are useful; they answer different questions. Override the outer dependency to bypass authentication wholesale; override the inner one when the code under test is the outer dependency itself.
The last line is the discipline. Overrides live on the app object, which is a module-level singleton shared by every test in the process. Always clear them in teardown:
import pytest
@pytest.fixture(autouse=True)
def clean_overrides():
yield
app.dependency_overrides.clear()
autouse=True is deliberate here — the one test that forgets to request the fixture is the one that poisons the suite. The broader substitution patterns, including overriding yield dependencies and settings objects, are covered in Overriding Dependencies in Tests.
Fixtures: Build Them in Layers
A good FastAPI suite has a short, boring fixture chain where each fixture does exactly one thing:
- Resource fixtures (session-scoped): an engine, a fake message broker, a stubbed HTTP layer. Expensive, built once.
- Isolation fixtures (function-scoped): a transaction that will be rolled back, a cleared cache.
- Wiring fixtures (function-scoped): install
dependency_overrides, hand back a client, clean up.
The database instance of that chain — a session-scoped engine, a per-test transaction, and a client bound to it — is worked through in full, with a passing transcript, in Testing with Async Database Fixtures.
The rule that keeps async fixtures working is about lifetimes: the event loop must live at least as long as the longest-lived async fixture. A session-scoped async fixture on a function-scoped loop is the classic cause of "attached to a different loop" errors, and the fix is configuration rather than code:
[pytest]
asyncio_mode = auto
asyncio_default_fixture_loop_scope = session
Fixtures also want to be values, not factories, wherever possible. A fixture that returns an already-open session lets the test assert against the same object the endpoint used, which is what makes "did the endpoint really write this row?" a one-line assertion.
Testing Background Tasks
BackgroundTasks are executed by Starlette as part of completing the response — the ASGI call does not finish until the tasks have run. Because both test clients await that same ASGI call, tasks have already completed when you get the response object back. No sleeping, no polling.
def test_background_task_has_already_run_when_testclient_returns():
AUDIT.clear()
with TestClient(app) as client:
response = client.post("/orders", params={"sku": "A-1"})
assert response.json() == {"sku": "A-1", "queued": True}
assert AUDIT == ["order:A-1:by:7"]
async def test_background_task_also_completes_under_asgitransport():
AUDIT.clear()
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://t") as client:
await client.post("/orders", params={"sku": "B-2"})
assert AUDIT == ["order:B-2:by:7"]
$ GET /pytest/background
200 OK
TestClient: audit right after the response = ['order:A-1:by:7']
.AsyncClient: audit right after the response = ['order:B-2:by:7']
.
2 passed in 0.00s
Both clients behave identically here, and both printed the side effect immediately after receiving the response. That determinism is a gift: assert on the observable effect (a row written, a message enqueued, a counter incremented) rather than on the task function having been called.
Two caveats. This is BackgroundTasks, not a real queue — the behaviour of an external worker cannot be asserted this way, and the differences are laid out in FastAPI BackgroundTasks vs Celery vs arq. And a task that raises does not fail the response, so a test asserting only on status code will not notice — see When BackgroundTasks Silently Fails.
Unit Versus Integration
The distinction that matters in a FastAPI codebase is not "fast versus slow" but "does the framework participate?"
A unit test covers something you could extract into a library: a pricing function, a Pydantic model's validators, a retry policy. No app, no client, no overrides. These should be the majority of your suite and should run in milliseconds.
An integration test goes through the app with one of the two clients. It is the only way to test routing, parameter binding, validation, response models, middleware ordering and dependency resolution — which is to say, most of what FastAPI is.
There is a tempting third option that is worse than either: importing the path operation function and calling it directly. It looks like a unit test and tests almost nothing.
$ GET /pytest/layers
200 OK
create_order parameters: ['user', 'tasks', 'sku']
annotation of `user` : typing.Annotated[dict, Depends(dependency=<function get_current_user at 0xADDR>, use_cache=True, scope=None)]
annotation of `tasks` : BackgroundTasks
-> calling create_order(...) yourself means constructing all three by hand
.missing required query param -> 422
{'detail': [{'type': 'missing', 'loc': ['query', 'sku'], 'msg': 'Field required', 'input': None}]}
.
2 passed in 0.00s
The first half is introspection of a real endpoint: to call create_order yourself you would have to fabricate a user dict, construct a BackgroundTasks instance, and supply sku directly — which means dependency resolution, background-task collection and query parameter parsing are all bypassed. The second half shows what you lose: going through the app, a missing sku produced a real 422 with a loc of ['query', 'sku']. Called directly, that request would have been a TypeError in your test, or worse, would have "passed" with a hand-made value that no real client could ever send.
A workable split for a typical service:
| Layer | Client | What it proves | Rough share |
|---|---|---|---|
| Domain functions and models | none | Business rules, validation logic | 60% |
| Endpoint behaviour | AsyncClient or TestClient with overrides | Routing, DI, validation, status codes | 30% |
| Database-backed flows | AsyncClient + transaction fixture | Real SQL, constraints, boundaries | 8% |
| Deployed smoke tests | real HTTP | TLS, proxy, config, migrations | 2% |
What to Assert on a Response
A large share of low-value FastAPI tests assert response.status_code == 200 and stop. That check passes for a handler returning the wrong user's data, so it is worth being deliberate about what an endpoint test is actually for.
Assert the status code and the body shape together. The status is the contract with HTTP; the body is the contract with the client. A response model change that silently drops a field will not move the status code.
async def test_create_returns_the_created_resource(client):
response = await client.post("/widgets", params={"name": "sprocket"})
assert response.status_code == 200
assert response.json() == {"id": 1, "name": "sprocket"}
Prefer whole-object equality over field-by-field checks where the payload is small. assert response.json() == {...} fails when a field is added as well as when one is removed, which is exactly the sensitivity you want from a serialization test.
Assert the loc of validation errors, not just the 422. FastAPI's error bodies are structured, and the loc tuple names the parameter and where it came from. Asserting on it means the test breaks when a parameter moves from query to body — a real API-breaking change that a bare status check would miss:
async def test_missing_sku_is_reported_against_the_query_string(client):
body = (await client.post("/orders")).json()
assert body["detail"][0]["loc"] == ["query", "sku"]
assert body["detail"][0]["type"] == "missing"
Assert side effects at their observable boundary. If an endpoint writes a row, query the row. If it enqueues a message, inspect the queue. Asserting that a mock was called with certain arguments couples the test to the implementation and keeps passing after a refactor that breaks the behaviour.
And assert the failure paths deliberately. The 401, the 403, the 409 on a conflicting write, the 422 on bad input: these are the responses your clients handle in production and the ones least likely to be exercised by hand. A useful heuristic is that every raise HTTPException in the codebase should be named by at least one test.
Async and Performance Notes
In-process tests are fast enough that suite time is usually dominated by fixtures rather than requests. Two habits keep it that way.
Make expensive things session-scoped and cheap things function-scoped. An engine, a loaded model, a compiled schema: build once. A transaction, an override, a cleared counter: rebuild per test.
Do not put time.sleep in a test. If you are waiting for something, either the thing is synchronous and you do not need to wait, or it is asynchronous and you should await it. The one legitimate use of wall-clock in a test is asserting on it — the timing assertions in Running Sync Code in a Threadpool and Concurrent Requests with asyncio.gather are the pattern, and they are the only reliable way to catch a blocking call in review.
Concurrency inside a single test needs AsyncClient. TestClient calls block the calling thread, so a loop of three requests really does take the sum of their durations, while asyncio.gather over an AsyncClient takes the maximum.
Failure Modes and Diagnosis
async def functions are not natively supported — pytest collected an async test with no async plugin active. Install pytest-asyncio and set asyncio_mode = auto.
Startup state is missing (AttributeError on app.state.x) — you used ASGITransport without running the lifespan, or built a TestClient outside a with block. Wrap it, or enter app.router.lifespan_context(app).
"attached to a different loop" — an async fixture and its consumer are on different event loops. Align asyncio_default_fixture_loop_scope with the fixture's scope.
Tests pass alone and fail together — an override or module-level state leaked. Add an autouse fixture that clears dependency_overrides, and check for caches or singletons built at import time.
Tests pass but the database is untouched — the session override is not installed, so the app is quietly using its real engine. Assert that the test's own session can see the row the endpoint wrote.
A 422 you did not expect — good news, actually: the request your test sends is not the request the endpoint declares. Read the loc field; it names the exact parameter and its source.
Everything passes and production breaks on the first request — your tests never ran the lifespan, so a misconfigured startup path was never exercised. Keep at least one test that enters the real lifespan.
FAQ
Should I use TestClient or httpx.AsyncClient?
Use TestClient for synchronous tests where you want the simplest possible call, and httpx.AsyncClient with ASGITransport whenever the test is async or depends on async fixtures. AsyncClient runs the app on the loop the test is already using, which is what lets fixtures and application code share loop-bound resources.
What does ASGITransport actually do? It is an httpx transport that calls your ASGI application directly in-process instead of opening a socket. There is no server, no port and no network, so requests are fast and deterministic while still going through the full middleware, routing, validation and serialization stack.
Does ASGITransport run startup and shutdown events?
No. ASGITransport only handles the http scope, so lifespan never runs and anything created at startup will be missing. Drive the lifespan yourself with the app's lifespan context, or use a lifespan manager helper.
How do I replace a dependency in a test?
Assign a replacement callable to app.dependency_overrides keyed by the original dependency function, then clear the overrides in fixture teardown. Overriding an outer dependency skips its sub-dependencies entirely, which is usually what you want for authentication.
Have background tasks finished when the test client returns?
Yes. BackgroundTasks are executed by Starlette as part of completing the response, so by the time the client hands you a response object the tasks have already run and you can assert on their side effects directly.
What belongs in a unit test versus an integration test? Unit tests cover plain functions and Pydantic models with no client at all. Integration tests go through the app so that routing, dependency resolution, validation and serialization are exercised. Calling a path operation function directly is neither, because it bypasses everything FastAPI does for you.
Related
- Up to the section: Async, Background Tasks and Observability.
- The client decision in depth: TestClient vs httpx AsyncClient measures what each one does to your event loop.
- Async test mechanics: Testing Async Endpoints with pytest-asyncio covers markers, modes and loop scopes.
- Isolating from the outside world: Mocking External Services in Tests replaces upstream HTTP without weakening the test.
- Database fixtures: Testing with Async Database Fixtures builds the rollback-per-test chain end to end.
- The substitution mechanism: Overriding Dependencies in Tests.