Tests that pass while production breaks are the worst kind. The 2026 FastAPI testing playbook prioritizes catching real bugs over speed. This post is the working setup.
The pyramid
- Unit (fast, many): pure logic, schemas, validators.
- Integration (medium, dozens): DB + service layer, real Postgres.
- Contract (medium, few): API shape via OpenAPI snapshot.
- End-to-end (slow, handful): real server, real DB, real client.
Mostly skip true unit tests for app code (validators are tested by Pydantic; schemas by use). Heavy on integration.
Real Postgres in tests
# conftest.py
import pytest_asyncio
from testcontainers.postgres import PostgresContainer
@pytest_asyncio.fixture(scope="session")
def postgres():
with PostgresContainer("postgres:18") as pg:
yield pg.get_connection_url().replace("postgresql://", "postgresql+asyncpg://")
@pytest_asyncio.fixture
async def db_engine(postgres):
engine = create_async_engine(postgres)
async with engine.begin() as c:
await c.run_sync(Base.metadata.create_all)
yield engine
await engine.dispose()
@pytest_asyncio.fixture
async def db(db_engine):
"""Per-test session in a transaction that rolls back."""
async with db_engine.connect() as conn:
async with conn.begin() as trans:
session = AsyncSession(bind=conn)
yield session
await session.close()
await trans.rollback()
Pattern:
- One Postgres container per session.
- Schema created once.
- Each test runs in a transaction; rolled back at end.
- No state leakage between tests.
Tests run in a few seconds; same code path as production.
ASGITransport for in-process tests
@pytest_asyncio.fixture
async def client(db):
app.dependency_overrides[get_db] = lambda: db
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
yield c
app.dependency_overrides.clear()
Tests hit the real FastAPI app in-process. No HTTP server, no port. Fast.
A real test
async def test_create_user(client):
r = await client.post("/users", json={"email": "[email protected]", "full_name": "A"})
assert r.status_code == 201
assert r.json()["email"] == "[email protected]"
async def test_duplicate_email_returns_409(client):
await client.post("/users", json={"email": "[email protected]", "full_name": "A"})
r = await client.post("/users", json={"email": "[email protected]", "full_name": "B"})
assert r.status_code == 409
Two integration tests. Real DB. Real validation. Real conflict handling.
Contract testing
def test_openapi_unchanged():
spec = app.openapi()
saved = json.loads(Path("tests/__snapshots__/openapi.json").read_text())
assert spec == saved
The test fails when the API shape changes. Forces deliberate updates to the snapshot. Catches accidental breaking changes.
End-to-end
@pytest.mark.e2e
async def test_full_signup_flow():
"""Hits a real running staging server."""
async with AsyncClient(base_url=os.environ["E2E_URL"]) as c:
r = await c.post("/auth/signup", ...)
assert r.status_code == 201
# ... walk through critical path
Run E2E in CI on every deploy to staging. Manual gating for prod.
Mocks (where they’re appropriate)
External services: Stripe, OpenAI, Twilio. Mock these:
@pytest.fixture
def mock_stripe(monkeypatch):
async def fake_charge(*a, **kw):
return {"id": "ch_test", "amount": 1000}
monkeypatch.setattr("app.payments.stripe.charge", fake_charge)
Don’t make real charges in tests. Don’t hit real LLM APIs.
What I’d ship
For a new FastAPI codebase:
- pytest + pytest-asyncio.
- testcontainers Postgres for integration tests.
- ASGITransport for in-process HTTP.
- Per-test rollback for isolation.
- OpenAPI snapshot test.
- Handful of E2E tests in CI on staging deploys.
- Coverage threshold in CI (~70%; aim for new code ~90%).
Combined: <30 seconds of test for a typical PR; catches almost all real bugs.
Read this next
- FastAPI + Pydantic v2 + SQLAlchemy 2.0 Production Patterns
- Testing FastAPI Apps
- Modern Python Tooling 2026
- SQLAlchemy 2.0 Deep Patterns
If you want a FastAPI test harness with all of this wired up, it’s at rajpoot.dev .
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 .