In this post
What this post covers
FastAPI is unusually easy to test: the app is a callable, clients run it in-process, and dependency overrides replace any collaborator. The hard part is not the tools but the design of the suite. Each test gets a fresh app and database, the lifespan runs, authentication is faked at the right seam, async code is tested asynchronously, and the API contract itself is pinned. This part writes such a suite and runs it with pytest.
A flight simulator
Pilots practise in a simulator that behaves like the real aircraft, where engines can be made to fail on demand and every session starts from the same known state. Good API tests are that simulator: the real routing, validation and database, with the dangerous parts (payments, email, the clock) replaced by controllable stand-ins, and a clean slate for every test.
1. The tools
| Tool | Use |
|---|---|
TestClient(app) in a with block | synchronous tests; runs the lifespan; WebSockets via websocket_connect |
httpx.AsyncClient(transport=ASGITransport(app)) | async tests (with pytest-asyncio or anyio); lets tests await other async code alongside requests. It does not run the lifespan, and it does not pass lifespan state to requests (the fixture below does both). |
app.dependency_overrides | replace auth, clocks, external clients, the database session |
| an app factory | a fresh app, with test settings, for each test (part 07) |
pytest fixtures and parametrize | setup, teardown, and many cases per test |
2. A complete suite, run for real
This block writes a small app (async SQLAlchemy, a bearer-token dependency, a WebSocket) and a test suite, then runs pytest on it:
app.py + conftest.py + test_items.py, then pytest
import subprocess
import sys
from pathlib import Path
Path("app.py").write_text('''
from contextlib import asynccontextmanager
from typing import Annotated
from fastapi import Depends, FastAPI, Header, HTTPException, Request, WebSocket
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy import String, select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
from sqlalchemy.pool import StaticPool
class Base(DeclarativeBase): pass
class Item(Base):
__tablename__ = "items"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(50))
owner_id: Mapped[int]
class ItemIn(BaseModel):
model_config = ConfigDict(extra="forbid")
name: str = Field(min_length=1, max_length=50)
class ItemOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
name: str
owner_id: int
def create_app(database_url: str = "sqlite+aiosqlite://") -> FastAPI:
@asynccontextmanager
async def lifespan(app):
engine = create_async_engine(database_url, poolclass=StaticPool) # one shared in-memory DB
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield {"sessions": async_sessionmaker(engine, expire_on_commit=False)}
await engine.dispose()
app = FastAPI(lifespan=lifespan)
async def get_session(request: Request):
async with request.state.sessions() as s:
yield s
async def current_user(authorization: Annotated[str | None, Header()] = None) -> int:
raise HTTPException(401, "real token validation lives here") # replaced in tests
app.state.current_user = current_user
Session = Annotated[AsyncSession, Depends(get_session)]
User = Annotated[int, Depends(current_user)]
@app.post("/items", status_code=201)
async def create(body: ItemIn, session: Session, user: User) -> ItemOut:
item = Item(name=body.name, owner_id=user)
session.add(item)
await session.commit()
return item
@app.get("/items/{item_id}")
async def read(item_id: int, session: Session, user: User) -> ItemOut:
item = await session.get(Item, item_id)
if item is None or item.owner_id != user:
raise HTTPException(404, "not found")
return item
@app.websocket("/ws/echo")
async def echo(ws: WebSocket):
await ws.accept()
await ws.send_text((await ws.receive_text()).upper())
await ws.close()
return app
''', encoding="utf-8")
Path("conftest.py").write_text('''
import httpx
import pytest
from fastapi.testclient import TestClient
from app import create_app
@pytest.fixture
def app():
return create_app() # fresh app + fresh in-memory database per test
@pytest.fixture
def as_user(app):
def login(user_id: int):
app.dependency_overrides[app.state.current_user] = lambda: user_id
yield login
app.dependency_overrides.clear() # never leak overrides into the next test
@pytest.fixture
def client(app):
with TestClient(app) as c: # "with" runs the lifespan
yield c
@pytest.fixture
def anyio_backend():
return "asyncio"
@pytest.fixture
async def aclient(app):
async with app.router.lifespan_context(app) as state: # ASGITransport skips lifespan: run it ourselves
async def with_state(scope, receive, send): # ...and hand its state to every request
scope["state"] = dict(state)
await app(scope, receive, send)
transport = httpx.ASGITransport(app=with_state)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as c:
yield c
''', encoding="utf-8")
Path("test_items.py").write_text('''
import json
import pytest
def test_requires_authentication(client):
assert client.post("/items", json={"name": "tea"}).status_code == 401
def test_create_then_read(client, as_user):
as_user(7)
created = client.post("/items", json={"name": "tea"})
assert created.status_code == 201
assert client.get(f"/items/{created.json()['id']}").json() == {"id": 1, "name": "tea", "owner_id": 7}
def test_other_users_items_are_invisible(client, as_user):
as_user(7)
item_id = client.post("/items", json={"name": "tea"}).json()["id"]
as_user(9)
assert client.get(f"/items/{item_id}").status_code == 404
@pytest.mark.parametrize("payload, field", [({"name": ""}, "name"), ({}, "name"),
({"name": "x", "owner_id": 1}, "owner_id")])
def test_validation(client, as_user, payload, field):
as_user(7)
r = client.post("/items", json=payload)
assert r.status_code == 422 and r.json()["detail"][0]["loc"][-1] == field
def test_each_test_gets_a_fresh_database(client, as_user):
as_user(7)
assert client.post("/items", json={"name": "first"}).json()["id"] == 1
def test_websocket(client):
with client.websocket_connect("/ws/echo") as ws:
ws.send_text("hello")
assert ws.receive_text() == "HELLO"
def test_openapi_contract(client):
paths = client.get("/openapi.json").json()["paths"]
assert sorted(paths) == ["/items", "/items/{item_id}"]
assert sorted(paths["/items"]["post"]["responses"]) == ["201", "422"]
@pytest.mark.anyio
async def test_async_client(app, aclient):
app.dependency_overrides[app.state.current_user] = lambda: 3
await aclient.post("/items", json={"name": "async tea"})
r = await aclient.get("/items/1")
assert r.json()["owner_id"] == 3
app.dependency_overrides.clear()
''', encoding="utf-8")
result = subprocess.run([sys.executable, "-m", "pytest", "-v", "-p", "no:cacheprovider", "-p", "anyio",
"-W", "ignore:Using `httpx`"], capture_output=True, text=True)
for line in result.stdout.splitlines():
if "::" in line or " passed" in line or " failed" in line:
print(line.split(" [")[0].replace("test_items.py::", " ").rstrip() if "::" in line
else line.split(" in ")[0].strip("= "))
Output
test_requires_authentication PASSED
test_create_then_read PASSED
test_other_users_items_are_invisible PASSED
test_validation[payload0-name] PASSED
test_validation[payload1-name] PASSED
test_validation[payload2-owner_id] PASSED
test_each_test_gets_a_fresh_database PASSED
test_websocket PASSED
test_openapi_contract PASSED
test_async_client PASSED
10 passed
A fresh world per test
The app fixture builds a new app, so the lifespan creates a new in-memory database. test_each_test_gets_a_fresh_database proves ids restart at 1.
Fake auth at the seam
Tests override one dependency, current_user, instead of minting real tokens. A separate test covers the real token validation.
Pin the contract
The OpenAPI test fails if someone renames a path or drops a documented response, which is a breaking change for clients.
3. Test databases: strategies
| Strategy | Speed | Fidelity | Use |
|---|---|---|---|
| SQLite in memory (above) | fastest | SQL dialect differences from PostgreSQL | most unit and API tests |
| Real PostgreSQL in Docker (testcontainers), transaction per test rolled back | fast after start-up | production dialect, constraints, JSONB, locks | integration tests, and anything using Postgres-specific SQL |
| Real PostgreSQL, create/drop schema per test | slowest | highest | migration tests |
transaction-per-test against a real database (pattern; needs PostgreSQL)
@pytest.fixture
async def session(engine):
async with engine.connect() as conn:
outer = await conn.begin() # everything below is rolled back
async with AsyncSession(bind=conn, join_transaction_mode="create_savepoint") as s:
yield s # app commits become SAVEPOINT releases
await outer.rollback()
@pytest.fixture
def app(session):
app = create_app()
app.dependency_overrides[get_session] = lambda: session # every request uses the test's session
return app
4. What else senior teams test
Authorization matrices
For each endpoint, a parametrised test with every role (and "another user's object"), asserting 200/403/404. This is the cheapest insurance against OWASP API1 and API5 (part 18).
Property-based API tests
Schemathesis reads /openapi.json and fires thousands of generated requests, finding 500s on inputs you never imagined.
Time and randomness
Inject a clock dependency (or use time-machine) so expiry logic is testable; seed random data.
External calls
Override the HTTP client dependency with an httpx.MockTransport; test timeouts and 5xx handling on purpose.
With Starlette 1.x, importing TestClient warns that it will move to httpx2. Add
httpx2 to your dev dependencies (FastAPI's own test suite did this in 0.137), or filter the warning
in your pytest configuration as the run above does with -W.
Recap
- Fresh app per test via the factory;
with TestClient(app)runs the lifespan. - Override dependencies (auth, clock, clients, session) at the seam, and clear them in fixture teardown.
- Async tests with
AsyncClient+ASGITransport, running the lifespan yourself. - Test the dangerous paths: authorization matrices, validation, an OpenAPI contract snapshot, and property-based fuzzing.
Get the free RAG production checklist
25 checks to run before real users see your RAG feature: exact-match misses, invented citations, prompt injection through documents, quality that slips after a prompt change. Confirm your email and the PDF comes with the welcome email, plus each new experiment.