Skip to main content

Command Palette

Search for a command to run...

Testing FastAPI Services: Unit, Integration, and Contract Tests

S01E03 of FastAPI in Production

Updated
5 min readView as Markdown
Testing FastAPI Services: Unit, Integration, and Contract Tests

The test suite was green. All 47 tests passing, CI clean, PR merged. Two hours later someone flagged that the staging database had 3,000 rows of test order data in the orders table.

A test that was supposed to mock the database wasn't. The mock was patched at the wrong path — the module where the function was defined, not where it was imported and used. Real DB calls went through. Real rows got written. Green tests, real side effects.

This is the most common testing mistake in FastAPI services, and it's entirely avoidable.


Project Setup

tests/
├── conftest.py          # shared fixtures: app, client, db, mocks
├── unit/
│   └── test_models.py   # Pydantic validation, pure logic
├── integration/
│   └── test_routes.py   # full request → response against real dependencies
└── contract/
    └── test_contracts.py # schema compatibility between producer and consumer

One conftest.py at the root. Fixtures flow down — integration tests use the same client fixture as unit tests, just with different dependency overrides.


The Mock Path Problem

The bug from the story above:

# services/orders.py
from db import get_pool  # defined here

async def create_order(payload): ...

# ❌ Wrong — patches where it's defined, not where it's used
@patch("db.get_pool")
async def test_create_order(mock_pool): ...

# ✅ Correct — patch where the function is imported and called
@patch("services.orders.get_pool")
async def test_create_order(mock_pool): ...

Always patch at the import site, not the definition site. If services/orders.py imports get_pool from db, patch services.orders.get_pool.

FastAPI's dependency injection makes this cleaner — use app.dependency_overrides instead of @patch wherever possible:

# conftest.py
import pytest
from httpx import AsyncClient, ASGITransport
from main import app
from db import get_db

async def override_get_db():
    # return a test session or mock
    yield MockDB()

@pytest.fixture
def client():
    app.dependency_overrides[get_db] = override_get_db
    yield  # tests run here
    app.dependency_overrides.clear()  # always clean up

@pytest.fixture
async def async_client(client):
    async with AsyncClient(
        transport=ASGITransport(app=app),
        base_url="http://test"
    ) as ac:
        yield ac

dependency_overrides swaps the real dependency for the test version at the FastAPI layer — no patching required, no wrong-path bugs.


Unit Tests — Pure Logic Only

Unit tests should have zero I/O. No DB, no HTTP, no Redis. Test Pydantic models, business logic, and utility functions:

# tests/unit/test_models.py
import pytest
from models.commerce import OrderPlacedEvent

def test_order_event_requires_amount():
    with pytest.raises(ValueError):
        OrderPlacedEvent(order_id="ord_1", user_id="usr_1", payload={})
        # amount is required — missing field raises

def test_order_event_legacy_field_accepted():
    event = OrderPlacedEvent(
        order_id="ord_1",
        user_id="usr_1",
        payload={"total_amount": 99.99}  # old field name
    )
    assert event.payload["amount"] == 99.99  # transition validator fired

Fast, no fixtures, no setup. These should run in milliseconds.


Integration Tests — Full Request Stack

Integration tests hit the actual FastAPI routes with real request/response cycles, but with controlled dependencies:

# tests/integration/test_routes.py
import pytest
from httpx import AsyncClient

@pytest.mark.anyio
async def test_ingest_valid_event(async_client: AsyncClient, mock_publisher):
    response = await async_client.post("/events/ingest", json={
        "event_type": "order.placed",
        "user_id": "usr_8821",
        "order_id": "ord_77234",
        "payload": {"amount": 89.99, "currency": "USD"}
    })

    assert response.status_code == 202
    assert "event_id" in response.json()
    mock_publisher.assert_called_once()

@pytest.mark.anyio
async def test_ingest_unknown_event_type(async_client: AsyncClient):
    response = await async_client.post("/events/ingest", json={
        "event_type": "nonexistent.event",
        "user_id": "usr_8821",
        "payload": {}
    })

    assert response.status_code == 422

@pytest.mark.anyio
async def test_health_check_degraded_when_db_down(async_client: AsyncClient, broken_db):
    response = await async_client.get("/health")
    assert response.status_code == 503

The mock_publisher and broken_db fixtures live in conftest.py and swap real dependencies via dependency_overrides. The routes, validation, exception handlers, and response shapes all get exercised — just not the real infrastructure.

Add anyio mode to pytest.ini or pyproject.toml:

[tool.pytest.ini_options]
anyio_mode = "auto"

Contract Tests — Schema Compatibility

Contract tests answer one question: if the producer changes its schema, does the consumer still work?

# tests/contract/test_contracts.py
from models.commerce import OrderPlacedEvent   # producer model
from consumer.models import IncomingOrderEvent  # consumer model
import json

def test_producer_output_satisfies_consumer_contract():
    # Simulate what the producer publishes
    producer_event = OrderPlacedEvent(
        order_id="ord_77234",
        user_id="usr_8821",
        payload={"amount": 89.99, "currency": "USD"}
    )

    # Serialize as the producer would (to Pub/Sub)
    published = json.loads(producer_event.model_dump_json())

    # Deserialize as the consumer would
    try:
        IncomingOrderEvent(**published)
    except Exception as e:
        pytest.fail(f"Consumer cannot parse producer output: {e}")

This test runs in CI on every PR. If a producer field rename would break the consumer, this catches it before merge — not after 3,000 rows of bad data.

For teams using a shared models package (covered in S01E02), this test is even simpler — the same class is imported on both sides, and the contract is enforced by the package version.


CI Configuration

# .github/workflows/test.yml
name: Test

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest

    services:
      postgres:
        image: postgres:15
        env:
          POSTGRES_PASSWORD: test
          POSTGRES_DB: pulsecart_test
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5

    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"

      - run: pip install -r requirements.txt

      - run: pytest tests/ -v --tb=short
        env:
          DATABASE_URL: postgresql://postgres:test@localhost/pulsecart_test
          ENVIRONMENT: test

Real Postgres in CI via GitHub Actions services — no mocking the DB layer in integration tests, just a throwaway test database that gets created fresh on every run. Unit and contract tests run against it too, but don't touch it.


The Three Rules

Mock at the import site, not the definition site. Or better — use dependency_overrides and skip @patch entirely.

Unit tests have zero I/O. If a unit test needs a database or an HTTP call, it's an integration test.

Contract tests run in CI on every PR. Schema mismatches are a merge-time problem, not a production problem.

Next: S01E04 — FastAPI Background Tasks vs Pub/Sub vs Cloud Tasks: when each one breaks.

FastAPI in Production

Part 3 of 3

A production-focused series on running FastAPI in the real world — not the quickstart, not the tutorial. Each post covers one gap between "works locally" and "runs reliably under load": lifespan management, Pydantic contracts, testing strategy, async work patterns, and deployment configuration. Written from real experience shipping FastAPI services on GCP at scale.

Start from the beginning

What the FastAPI Docs Don't Tell You About Production

S01E01 of FastAPI in Production