FastAPI Testing¶
Best practices for testing FastAPI applications with pytest.
Agent Quick Path¶
Use this sequence before reading the full reference:
- If test is pure route behavior, use
TestClientand plaindeftests. - If test must
awaitother async work, useAsyncClient+@pytest.mark.anyio. - Prefer
app.dependency_overridesovermock.patch. - Reset overrides after each test/fixture teardown.
- For startup/shutdown logic, use
TestClientas context manager orLifespanManagerwith async client.
Decision rules:
- Need DB contract verification: choose integration tests and override get_db/get_session.
- Need pure business logic checks: keep tests HTTP-free (unit).
- Need one critical path sanity check: one endpoint per smoke test.
Tools¶
| Tool | Purpose |
|---|---|
fastapi.testclient.TestClient |
Synchronous HTTP test client (wraps HTTPX, built on Starlette) |
httpx.AsyncClient + ASGITransport |
Async client for tests that await other async code |
app.dependency_overrides |
Replace any Depends() dependency for the duration of a test |
anyio / pytest-anyio |
Run async test functions with @pytest.mark.anyio |
Install deps: httpx, anyio (or pytest-anyio).
Synchronous Tests (Preferred Default)¶
Use TestClient for route tests that don't need to await anything else.
Test functions are plain def — no async def, no await.
from fastapi.testclient import TestClient
from myapp.main import app
client = TestClient(app)
def test_read_item_returns_200():
response = client.get("/items/foo", headers={"X-Token": "secret"})
assert response.status_code == 200
assert response.json()["id"] == "foo"
def test_read_item_bad_token_returns_400():
response = client.get("/items/foo", headers={"X-Token": "wrong"})
assert response.status_code == 400
Rules:
- One TestClient per test module is fine (stateless between calls).
- Pass headers, query params, JSON body, or form data the same way as HTTPX/requests.
- Do not pass Pydantic models directly; use .model_dump() or jsonable_encoder.
Async Tests¶
Use AsyncClient only when the test itself needs to await other coroutines
(e.g. querying a real async DB after an API call to verify side effects).
import pytest
from httpx import ASGITransport, AsyncClient
from myapp.main import app
@pytest.mark.anyio
async def test_root_async():
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac:
response = await ac.get("/")
assert response.status_code == 200
Rules:
- Mark with @pytest.mark.anyio; register the anyio marker in pyproject.toml.
- AsyncClient does not trigger lifespan events by default; use asgi-lifespan's
LifespanManager when startup/shutdown matters.
- Instantiate objects that require an event loop (e.g. async DB clients) inside
async functions, not at module level.
Dependency Overrides (Preferred Over Mocking)¶
app.dependency_overrides is the idiomatic FastAPI seam — use it instead of
patching internals with unittest.mock.
from fastapi.testclient import TestClient
from myapp.main import app
from myapp.deps import get_current_user
client = TestClient(app)
def fake_user():
return {"id": 1, "name": "Test User"}
def test_protected_route_with_fake_user():
app.dependency_overrides[get_current_user] = fake_user
response = client.get("/me")
app.dependency_overrides = {} # always reset after the test
assert response.status_code == 200
assert response.json()["name"] == "Test User"
Or reset cleanly with autouse=False fixture teardown:
import pytest
from myapp.main import app
@pytest.fixture()
def override_user():
app.dependency_overrides[get_current_user] = lambda: {"id": 1, "name": "Test User"}
yield
app.dependency_overrides = {}
Rules:
- Override at the lowest-level dependency that owns the external boundary
(e.g. get_db, get_current_user, get_settings).
- Always reset app.dependency_overrides after each test or fixture teardown.
- Prefer a real in-process fake (e.g. in-memory SQLite session) over a mock object.
Database Testing¶
The preferred pattern is a real SQLite (or test Postgres) session injected via dependency override, not a mock.
# tests/conftest.py
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from fastapi.testclient import TestClient
from myapp.main import app
from myapp.db.session import get_db
from myapp.db.models import Base
TEST_DB_URL = "sqlite:///./test.db"
@pytest.fixture(scope="session")
def engine():
e = create_engine(TEST_DB_URL, connect_args={"check_same_thread": False})
Base.metadata.create_all(bind=e)
yield e
Base.metadata.drop_all(bind=e)
@pytest.fixture()
def db_session(engine):
Session = sessionmaker(bind=engine)
session = Session()
yield session
session.rollback()
session.close()
@pytest.fixture()
def client(db_session):
app.dependency_overrides[get_db] = lambda: db_session
yield TestClient(app)
app.dependency_overrides = {}
Rules:
- Prefer session-scoped engine creation; function-scoped session with rollback per test.
- Keep unit tests DB-free; use this pattern only in integration-marked tests.
- For async SQLAlchemy, mirror the same pattern using AsyncEngine / AsyncSession.
Lifespan and Startup Events¶
TestClient triggers lifespan events (startup/shutdown) when used as a context manager:
def test_with_lifespan():
with TestClient(app) as client:
response = client.get("/health")
assert response.status_code == 200
For AsyncClient, use asgi-lifespan:
from asgi_lifespan import LifespanManager
@pytest.mark.anyio
async def test_with_async_lifespan():
async with LifespanManager(app):
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac:
response = await ac.get("/health")
assert response.status_code == 200
Marker Strategy for FastAPI Tests¶
| Marker | When to use |
|---|---|
unit |
Pure service/utility logic with no HTTP or DB calls |
integration |
TestClient + real DB session via dependency override |
smoke |
One TestClient call per critical user path, no DB reset |
external |
Tests that call real third-party APIs (skip in CI by default) |
Quick Reference: Sending Data¶
| What to send | Parameter |
|---|---|
| Path / query param | Part of the URL string |
| JSON body | json={"key": "value"} |
| Form data | data={"field": "value"} |
| Headers | headers={"X-Token": "..."} |
| Cookies | cookies={"session": "..."} |
| File upload | files={"file": ("name.txt", b"content", "text/plain")} |