Fast, reliable API tests catch regressions in validation, auth and serialization before users do.
TestClient (from fastapi.testclient, built on HTTPX) sends requests straight into your ASGI app with no server or network. It has the familiar client.get, .post(json=...), .put, .delete methods, and each returns a response with .status_code, .json() and .headers. Tests are ordinary synchronous pytest functions, even when your endpoints are async def.
Use it as a context manager, with TestClient(app) as client:, when your app has a lifespan: entering runs startup and leaving runs shutdown. Without with, the lifespan never runs, and anything it creates is missing.
The real power is app.dependency_overrides, a dict that maps an original dependency to a replacement. Override get_session to use a test database, current_user to skip logging in, get_settings to flip a feature flag. Overrides are looked up on every request, so set them in a fixture and clear them afterwards with app.dependency_overrides.clear(), otherwise they leak into other tests.
What's worth testing:
- the happy path and the exact response shape (
assert r.json() == {...}), - validation: bad input returns 422,
- auth: no token gives 401, the wrong role 403,
- error mapping: missing resources give 404, and custom handlers produce their format,
- the schema:
client.get("/openapi.json")returns 200, which catches models that can't be documented.
For a test database, SQLite in a temp file or a disposable Postgres container both work; the override pattern is the same. When a test itself must await things, such as async database setup, use httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") with an async test plugin like AnyIO's.
Example
# test_app.py, run with: pytest -q
from typing import Annotated
import pytest
from fastapi import Depends, FastAPI, HTTPException
from fastapi.testclient import TestClient
def current_user() -> str:
raise HTTPException(status_code=401, detail="Not authenticated") # real one checks a token
app = FastAPI()
@app.get("/me")
def me(user: Annotated[str, Depends(current_user)]):
return {"user": user}
@pytest.fixture
def client():
app.dependency_overrides[current_user] = lambda: "ann"
with TestClient(app) as c:
yield c
app.dependency_overrides.clear()
def test_me(client):
assert client.get("/me").json() == {"user": "ann"}
def test_requires_login():
assert TestClient(app).get("/me").status_code == 401Remember
- TestClient calls the app in-process; tests stay synchronous
- Use
with TestClient(app)so lifespan startup and shutdown run - app.dependency_overrides swaps dependencies; clear it after each test
- Test the error paths (401, 404, 422) as well as the happy path
Watch out for
- Leaving dependency overrides in place between tests, so later tests pass or fail for the wrong reason.