Chapter 10: how to test FastAPI. The patterns that scale: dependency overrides, real DBs via testcontainers, fixtures, factories, CI. We use pytest + httpx async client by default.
Test setup
uv add --dev pytest pytest-anyio httpx pytest-cov
conftest.py:
import pytest
from httpx import AsyncClient
from app.main import app
@pytest.fixture
async def client():
async with AsyncClient(app=app, base_url="http://test") as ac:
yield ac
Run async tests:
import pytest
@pytest.mark.anyio
async def test_root(client):
r = await client.get("/")
assert r.status_code == 200
Or set pytest_plugins = ["anyio"] and use @pytest.mark.anyio per test.
TestClient (sync)
from fastapi.testclient import TestClient
client = TestClient(app)
def test_root():
r = client.get("/")
assert r.status_code == 200
TestClient is sync; uses ASGI underneath. Good for simple tests; AsyncClient is more flexible.
Dependency overrides
# app
async def get_db() -> AsyncSession: ...
# test
@pytest.fixture
async def db_override():
async with TestSessionLocal() as session:
yield session
@pytest.fixture
async def client(db_override):
app.dependency_overrides[get_db] = lambda: db_override
async with AsyncClient(app=app, base_url="http://test") as ac:
yield ac
app.dependency_overrides.clear()
Override real deps with test versions. Non-invasive; works for any Depends.
Real DB with testcontainers
from testcontainers.postgres import PostgresContainer
@pytest.fixture(scope="session")
def postgres():
with PostgresContainer("postgres:17") as pg:
yield pg
@pytest.fixture(scope="session")
async def engine(postgres):
engine = create_async_engine(postgres.get_connection_url())
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield engine
await engine.dispose()
@pytest.fixture
async def db_session(engine):
async with AsyncSession(engine) as session:
yield session
await session.rollback()
Each test runs in a fresh transaction; rolled back at the end.
For integration tests against real Postgres: this is the pattern. See Go Testing Patterns for similar.
Per-test transactions
@pytest.fixture
async def db_session(engine):
connection = await engine.connect()
transaction = await connection.begin()
async with AsyncSession(bind=connection) as session:
yield session
await transaction.rollback()
await connection.close()
Every test sees an empty DB (rollback after). Fast; reliable.
Fixtures and factories
# factories.py
import factory
class UserFactory(factory.Factory):
class Meta:
model = User
email = factory.Faker("email")
full_name = factory.Faker("name")
is_active = True
# tests/test_users.py
async def test_create_user(client, db_session):
user = UserFactory.build()
db_session.add(user)
await db_session.commit()
r = await client.get(f"/users/{user.id}")
assert r.status_code == 200
factory_boy builds test instances. Reusable; less boilerplate per test.
Auth in tests
@pytest.fixture
async def auth_client(client, db_session):
user = UserFactory.build()
db_session.add(user)
await db_session.commit()
token = make_test_token(user.id)
client.headers["Authorization"] = f"Bearer {token}"
yield client
Or override get_current_user:
@pytest.fixture
def auth_client(client):
test_user = User(id=1, email="[email protected]", role="admin")
app.dependency_overrides[get_current_user] = lambda: test_user
yield client
app.dependency_overrides.pop(get_current_user)
WebSocket tests
def test_ws():
with TestClient(app).websocket_connect("/ws") as websocket:
websocket.send_json({"hello": "world"})
data = websocket.receive_json()
assert data == {"echo": {"hello": "world"}}
Sync TestClient supports WebSocket. For async + WebSocket: use AsyncClient with httpx-ws or test the handler directly.
Mocking external HTTP
import respx
@pytest.fixture
def mock_external():
with respx.mock(base_url="https://external.api") as mock:
mock.get("/users/1").respond(json={"id": 1, "name": "test"})
yield mock
async def test_calls_external(client, mock_external):
r = await client.get("/proxy/users/1")
assert mock_external.calls.call_count == 1
respx for httpx; clean recording / matching.
Mocking time
from freezegun import freeze_time
@freeze_time("2026-01-01")
async def test_time_dependent(client):
r = await client.get("/today")
assert r.json()["date"] == "2026-01-01"
Or with pytest fixtures.
Mocking environment
@pytest.fixture
def settings_override(monkeypatch):
monkeypatch.setenv("MYAPP_DATABASE_URL", "sqlite:///:memory:")
monkeypatch.setenv("MYAPP_SECRET_KEY", "test-secret")
For pydantic-settings: works because it reads env on instantiation.
OpenAPI testing
def test_openapi_schema():
schema = app.openapi()
paths = schema["paths"]
assert "/users" in paths
assert "post" in paths["/users"]
Snapshot tests against expected OpenAPI shape. Catches accidental API breakage.
Coverage
pytest --cov=src --cov-report=term --cov-report=html
Aim for high coverage of business logic; less obsession over trivial routes.
Parametrize
@pytest.mark.parametrize("payload, expected", [
({"email": "[email protected]", "name": "x"}, 201),
({"email": "invalid", "name": "x"}, 422),
({"email": "[email protected]"}, 422), # missing name
])
async def test_create_user_validation(client, payload, expected):
r = await client.post("/users", json=payload)
assert r.status_code == expected
DRY for input validation tests.
Async fixtures with anyio
@pytest.fixture(scope="session")
def anyio_backend():
return "asyncio"
Tells anyio to use asyncio backend. Avoids running tests on every backend.
CI
# .github/workflows/test.yml
- uses: actions/setup-python@v5
with: { python-version: "3.13" }
- run: uv sync --frozen
- run: uv run pytest --cov=src --cov-fail-under=80
Postgres in CI:
services:
postgres:
image: postgres:17
env:
POSTGRES_PASSWORD: test
ports: ["5432:5432"]
Or use testcontainers (slower start; more isolated).
Common mistakes
1. Mocking the DB
AsyncMock for db.get(...). Tests pass; production fails. Use real DB.
2. Shared state across tests
Test A leaves rows; test B finds them. Use per-test transactions.
3. Real network calls
Tests slow / flaky / depending on third-party. Mock external HTTP.
4. No async fixture
@pytest.fixture without async. Use pytest-anyio and async def fixtures.
5. Forgetting to clear overrides
dependency_overrides set in one test leaks to next. Clear in fixture cleanup.
What’s next
Chapter 11: Observability — logging, tracing, metrics.
Read this next
Building something AI-, backend-, or data-heavy and want a second pair of eyes? I do consulting and freelance work — see my projects and ways to reach me at rajpoot.dev .