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:

  1. pytest + pytest-asyncio.
  2. testcontainers Postgres for integration tests.
  3. ASGITransport for in-process HTTP.
  4. Per-test rollback for isolation.
  5. OpenAPI snapshot test.
  6. Handful of E2E tests in CI on staging deploys.
  7. 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

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 .