A client that calls the app directly, no server, and what a good FastAPI test suite actually asserts.
Overview
No server involved
Testing a FastAPI app is easier than most people expect. The framework ships with a test client that calls your application directly, in the same process, without starting a server or opening a network port. You write a normal test function, make a request, and check the response.
This is valuable because it makes tests fast and reliable. A suite of hundreds of endpoint tests can run in seconds. There are no ports to clash, no processes to clean up, and no waiting for a server to boot. You test the real routing, validation, and serialization, which is exactly where bugs tend to hide.
This article starts with how the client works and why no server is involved. It then covers what a good test should assert. Checking the status code is the minimum. The shape of the body, the headers, and the error details tell you much more about whether the behavior is right.
Overrides are the next topic, and they are the heart of the approach. By replacing a dependency, you can feed the app a fake database or a fixed user, and keep the test isolated. You will also see how to test a single router alone, without the rest of the app.
The article ends with lifespan in tests, which needs one small adjustment so that startup and shutdown code actually runs. After reading, you will have a pattern you can reuse for every project. Reading this one carefully pays off, because the same pattern repeats in every test file you write.
TestClient doesn't start anything. It builds the ASGI scope a server would build, calls your use. Turns the response messages back into an object with .status_code and .json().
That is why FastAPI tests are fast: no socket, no port, no process, no waiting for a thing to come up. A test suite of a few hundred endpoint tests runs in seconds.
It's also why the editors on these pages work at all. They use the same idea.
In real code the import is from fastapi.testclient import TestClient. It wraps httpx. It needs httpx installed. This Is the usual reason a first test fails on a fresh setting.
Worth knowing
TestClient builds the ASGI scope and calls the app directly. No server, no port, no network — which is why the tests are fast.
Assert the status code first. A test that only checks the body passes when the endpoint starts returning the wrong status.
For validation, assert on loc and type. Messages are prose and get reworded.
dependency_overrides is what makes an endpoint testable without a database or a token — and clearing it between tests is not optional.
A router can be included into a small app built for one test file, so the rest of the application is not involved.
Assert on the generated schema too: a removed response_model is a breaking change that no functional test notices.
Testing
A client that calls the app right. What a good FastAPI test suite really asserts.
The client calls the app, not a server
There is no network. The client builds the ASGI scope and invokes the application, which is why tests are fast and need no port.
example_01.pyFastAPI
from fastapi import FastAPI
from pydantic import BaseModel, Field
app = FastAPI()
DB = {}
class ModuleIn(BaseModel):
title: str = Field(min_length=3)
@app.post("/modules", status_code=201)
def create(body: ModuleIn):
i = len(DB) + 1
DB[i] = body.title
return {"id": i, "title": body.title}
@app.get("/modules/{i}")
def read(i: int):
return {"id": i, "title": DB[i]}
client = TestClient(app)
r = client.post("/modules", json={"title": "Vectors"})
print("create:", r.status_code, r.json())
print("read :", client.get("/modules/1").json())
Output
Assert on status first, body second
The status is the contract; the body is the detail. A test that only checks the body passes when the endpoint starts failing.
example_02.pyFastAPI
from fastapi import FastAPI, HTTPException
app = FastAPI()
DB = {1: "Vectors"}
@app.get("/modules/{i}")
def read(i: int):
if i not in DB:
raise HTTPException(404, "Module not found")
return {"id": i, "title": DB[i]}
client = TestClient(app)
def check(path, expected_status):
r = client.get(path)
ok = r.status_code == expected_status
print("%-14s -> %s %s" % (path, r.status_code, "ok" if ok else "MISMATCH"))
return r
check("/modules/1", 200)
check("/modules/99", 404)
check("/modules/abc", 422)
Output
Testing validation properly
Assert on loc and type, never on the message — prose gets reworded and the suite starts failing for nothing.
example_03.pyFastAPI
from fastapi import FastAPI
from pydantic import BaseModel, Field
app = FastAPI()
class ModuleIn(BaseModel):
title: str = Field(min_length=3)
minutes: int = Field(gt=0)
@app.post("/modules")
def create(body: ModuleIn):
return {"ok": True}
client = TestClient(app)
r = client.post("/modules", json={"title": "x", "minutes": 0})
found = {(tuple(e["loc"]), e["type"]) for e in r.json()["detail"]}
expected = {(("body", "title"), "string_too_short"),
(("body", "minutes"), "greater_than")}
print("status :", r.status_code)
for loc, kind in sorted(found):
print(" ", loc, kind)
print("matches :", found == expected)
Output
Overriding what the endpoint depends on
The point of declaring dependencies: a test replaces the database and the user without touching the handler.
example_04.pyFastAPI
from fastapi import Depends, FastAPI, Header, HTTPException
app = FastAPI()
def get_db():
raise RuntimeError("no database in a test")
def current_user(x_token: str = Header(default="")):
raise HTTPException(401, "Sign in")
@app.get("/mine")
def mine(db=Depends(get_db), user=Depends(current_user)):
return {"user": user["name"], "rows": db["rows"]}
app.dependency_overrides[get_db] = lambda: {"rows": ["Vectors"]}
app.dependency_overrides[current_user] = lambda: {"name": "test-user"}
print(TestClient(app).get("/mine").json())
app.dependency_overrides.clear()
print("cleared, so the next test starts from the real thing")
Output
Testing one router alone
Include a router into a small app built for the test, and the rest of the application is not involved.
example_05.pyFastAPI
from fastapi import APIRouter, FastAPI
# Somewhere in the application:
modules = APIRouter(prefix="/modules", tags=["modules"])
@modules.get("")
def list_modules():
return ["Vectors", "Norms"]
@modules.get("/{i}")
def read(i: int):
return {"id": i}
# In the test: an app containing only what is under test.
app = FastAPI()
app.include_router(modules)
c = TestClient(app)
print(c.get("/modules").json())
print(c.get("/modules/7").json())
print()
print("No startup, no other routers, no unrelated dependencies.")
Output
Checking the contract itself
The schema is worth asserting on: a response model quietly removed is a breaking change no functional test would catch.
example_06.pyFastAPI
from fastapi import FastAPI
from pydantic import BaseModel
app = FastAPI()
class ModuleOut(BaseModel):
id: int
title: str
@app.get("/modules/{i}", response_model=ModuleOut)
def read(i: int):
return {"id": i, "title": "Vectors", "internal": "secret"}
spec = app.openapi()
schema = spec["components"]["schemas"]["ModuleOut"]
print("documented fields:", sorted(schema["properties"]))
print("required :", sorted(schema["required"]))
print()
r = TestClient(app).get("/modules/1")
print("response :", r.json())
print("no leak :", "internal" not in r.json())
Output
What to assert
Status first. It's the contract, and it's what each client branches on. A test that checks only the body will keep passing when a 200 quietly turns a 500 with an error body that happens to hold the key you looked for.
Then the body, on the fields that matter. Asserting the whole payload makes a test that fails each time an unrelated field is added. Brittle in a way that teaches people to update tests without reading them.
For check failures, assert on loc and type. Never on msg. It's prose, it gets reworded between releases. A suite that fails on wording is one people learn to skip.
Overrides are the point
all the dependencies tier argued for pays off here.
An endpoint declaring Depends(get_db) and Depends(current_user) is tested with neither a DB nor a token. Both are replaced at the app level. The handler is unchanged. Only what it depends on moves.
Two rules from that module apply right. Fake at the edges. The DB, the clock, the token decoder. So the real logic above them still runs. And clear between tests, or one test silently changes the next and the failure appears one spot unrelated.
In pytest the shape is a fixture that sets, yields and clears.
Testing a router alone
A router can be included into an app built for a single test file:
That test exercises one resource, with no other routers, no startup work and no unrelated dependencies. It's the quiet gain of splitting an use up. It's the gap between "the tests need a DB, Redis and three setting vars" and "the tests need the module under test".
Lifespan in tests
TestClient doesn't run the lifespan unless you ask. Used as a context manager it does:
with TestClient(app) as client:
...
Which you want depends on the test. For a unit test of one endpoint with all overridden, skipping startup is faster and more isolated. For an integration test that should exercise the real wiring, the context-manager form is correct.
Knowing the gap shows a common confusion: a test that fails with app.state.pool missing is a test that never ran startup.
What functional tests miss
Two things worth asserting apart.
The schema. Dropping a response_model, renaming a field, or loosening a type is a breaking change for consumers. A functional test that checks r.json()["id"] won't notice. Asserting on app.openapi(). That a model has the fields it should, that an endpoint docs its 404 — catches contract changes.
What isn't in the response. A test that the payload holds id and title passes just as happily when it also holds password_hash. If an endpoint filters a thing out, assert that it's absent, not merely that the wanted fields are present.
A suite worth having
For each endpoint: the success case with the values you expect, one check failure asserting loc and type. One domain failure. The 404 or the 409.
Beyond that: one test per dependency that can reject. The 401 and 403 paths are covered. And a small number of tests that assert on the schema for endpoints with consumers.
That is a few short functions per endpoint. Between them they cover the branches most likely to be wrong. The parts people skip. The error paths. Are the parts that get exercised most in production.
Mistakes people make with Testing
Asserting only on the body. A 200 that turns a 500 carrying a similar key keeps the test green. Status first.
**Asserting on msg.** It's prose, it gets reworded. A suite that fails on wording is one people stop reading. Use loc and type.
Asserting the whole payload. Then each unrelated field addition breaks the test. This Teaches people to update tests without reading them.
Losing to clear replaces. One test changes the next. The failure appears one spot unrelated with no visible cause.
Faking too high. replacing the permission dependency skips the permission logic you meant to test. Fake the edges.
Never running the lifespan. Then app.state is empty. The error names neither the cause nor the fix.
Only testing the happy path. The error branches are the ones exercised most in production and least in the suite.
What a suite should contain
Per endpoint: the success case with the values you expect. One check failure asserting loc and type. One domain failure - the 404 or the 409.
Across the use: one test per dependency that can reject. The 401 and 403 paths are covered. And a handful asserting on app.openapi() for endpoints with real consumers. A removed response_model is a breaking change no functional test notices.
That is a few short functions per endpoint. It covers the branches most likely to be wrong.
Speed and what it buys
A FastAPI suite is fast by default. The speed is worth protecting because it changes how the tests get used.
There's no server, so no startup cost per test. With dependencies overridden there's no DB, so no fixtures to load or transactions to roll back. a few hundred endpoint tests running in a couple of seconds is normal.
That matters because a suite people run constantly catches things a suite people run at the end doesn't. The moment it takes a minute, it stops being run between edits.
Two things erode it. Real I/O creeping back in through a dependency somebody forgot to replace. Integration tests that make the whole use per test rather than per module.
Both are worth watching, because the decline is gradual and the point where it stops being run isn't announced.
The parts people skip
Error paths, and they're the ones exercised most in production.
A test that a missing resource gives 404, a copy gives 409, a bad payload gives 422 with the right loc. An unauthenticated request gives 401 costs four short functions - and covers the branches most likely to be wrong. They're the branches no one exercises by hand.
Testing: summary
TestClient builds the ASGI scope and calls the app right. There's no server, no port and no waiting.
Assert the status first and the body second. For check, assert loc and type rather than the message. replace dependencies to drop the DB and the token, fake at the edges, and clear between tests.
Include a router into a small app to test one resource alone. Use the context-manager form when the test should exercise startup. And assert on the made schema for anything with consumers. A removed response_model is a breaking change nothing else catches.
What makes an endpoint hard to test
Worth naming, because the answer is often a design signal rather than a testing problem.
Work in the handler. Logic that only exists inside a request can only be tested through one. Moving it to a service makes it a function call.
Reaching instead of declaring. A handler calling get_session() in its body can't be given a fake. One declaring Depends(get_session) can.
Too many dependencies. If a test needs six replaces, the endpoint is entangled with six things - and the fixture count is a fair measure of that.
Import-time side effects. If importing the module connects to a thing, each test pays. The suite can't run without the world being present.
Each of those makes tests awkward and each is fixed by a change to the use rather than to the test. When a test is hard to write, the handy first question is what the endpoint is doing that it shouldn't.
What to test, and what not to
A suite is a set of choices about what is worth the maintenance. Two extremes are both wrong.
Testing each branch of each handler makes a suite that breaks on each refactor and gets updated without being read.
Testing only the happy paths leaves the branches that really run in production - the 404, the 422, the 401 - fully uncovered.
The middle is per endpoint: the success case, one check failure, one domain failure. Then, across the use, one test per dependency that can reject. A few asserting on the schema for anything with consumers.
That is small enough to keep and exact enough to be worth keeping. The measure is whether a failure tells you what broke without opening the test - and asserting on status, loc and type is what makes it do that.
A closing thought
The reason FastAPI uses tend to be well tested isn't discipline. It's that the framework removed the usual excuses.
There's no server to start. Tests are fast. Dependencies are declared rather than reached for. So They can be replaced. Routers can be included one at a time, so a test can be narrow. check happens at the boundary, so handlers have less to test.
What remains is writing the tests. The shape is small: success, check failure, domain failure, per endpoint.
Where to go after Testing
The doc all of this makes - and how much of an API's usability is decided by how carefully it was filled in.
Questions people ask about Testing
How do I test an endpoint? Create TestClient(app) from fastapi.testclient and call client.get("/path"), then assert on status_code and json().
Do I need a running server? No. TestClient calls the app directly in-process.
How do I avoid hitting a real database? Override the database dependency with dependency_overrides to return a test session.
How do I test async endpoints? TestClient handles them. For async test functions, use httpx.AsyncClient with ASGITransport and pytest-asyncio or anyio.
How do I send auth in tests? Pass headers={"Authorization": "Bearer token"} or override the current-user dependency.
What should I assert on? The status code, the response body shape, and side effects such as database rows, not the internals of the handler.
Testing in one line
No server, no port, no network: assert the status first, loc and type for check, replace at the edges, clear between tests. Cover the error branches. Those are the ones production exercises most and suites cover least.
The measure of a suite is whether a failure tells you what broke without opening the test file. Asserting on the status, the loc and the type is what makes it do that. Asserting on prose and whole payloads is what stops it.
And keep one test with nothing overridden, exercising the real dependency tree. Heavy faking has a failure mode of its own. The fakes quietly stop resembling what they stand in for. Each test keeps passing while the use stops working.
Check yourself
0 of 4
Answer without scrolling back up.
What does TestClient actually do?
No socket, no process, no waiting for anything to come up - which is why hundreds of endpoint tests run in seconds.
Why assert on the status code before the body?
The status is the contract every client branches on. Checking only the body leaves the most important change undetected.
Your test fails because `app.state.pool` is missing. What is likely wrong?
`with TestClient(app) as client:` runs startup and shutdown. Plain construction does not, which is often what you want for an isolated unit test.
Which breaking change would a normal functional test miss?
A test checking `r.json()["id"]` passes happily when the payload also gained `password_hash`. Assert on what should be absent, and on the schema.
Cheat sheet
Testing
Write tests with TestClient, call endpoints without a running server, override dependencies for isolation, and see what good tests assert.
Ashish Jangra builds and maintains VizLearn. Every module here is written and the visualisation behind it hand-built, so the numbers in a readout come from the same code that draws the picture. Corrections are genuinely welcome and get priority over everything else — if a page states something wrong, or an animation misrepresents what the algorithm does, get in touch.