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 .