7.4 KiB
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
smoketest.
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
TestClientper 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()orjsonable_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 theanyiomarker inpyproject.toml. AsyncClientdoes not trigger lifespan events by default; useasgi-lifespan'sLifespanManagerwhen 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_overridesafter 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")} |