Files
prompts/docs/skills/pytest-scaffolding/references/fastapi-testing.md
T
2026-06-19 17:22:34 -05:00

7.4 KiB

FastAPI Testing

Best practices for testing FastAPI applications with pytest.

Agent Quick Path

Use this sequence before reading the full reference:

  1. If test is pure route behavior, use TestClient and plain def tests.
  2. If test must await other async work, use AsyncClient + @pytest.mark.anyio.
  3. Prefer app.dependency_overrides over mock.patch.
  4. Reset overrides after each test/fixture teardown.
  5. For startup/shutdown logic, use TestClient as context manager or LifespanManager with async client.

Decision rules:

  • Need DB contract verification: choose integration tests and override get_db/get_session.
  • Need pure business logic checks: keep tests HTTP-free (unit).
  • Need one critical path sanity check: one endpoint per smoke test.

Tools

Tool Purpose
fastapi.testclient.TestClient Synchronous HTTP test client (wraps HTTPX, built on Starlette)
httpx.AsyncClient + ASGITransport Async client for tests that await other async code
app.dependency_overrides Replace any Depends() dependency for the duration of a test
anyio / pytest-anyio Run async test functions with @pytest.mark.anyio

Install deps: httpx, anyio (or pytest-anyio).


Synchronous Tests (Preferred Default)

Use TestClient for route tests that don't need to await anything else. Test functions are plain def — no async def, no await.

from fastapi.testclient import TestClient
from myapp.main import app

client = TestClient(app)

def test_read_item_returns_200():
    response = client.get("/items/foo", headers={"X-Token": "secret"})
    assert response.status_code == 200
    assert response.json()["id"] == "foo"

def test_read_item_bad_token_returns_400():
    response = client.get("/items/foo", headers={"X-Token": "wrong"})
    assert response.status_code == 400

Rules:

  • One TestClient per test module is fine (stateless between calls).
  • Pass headers, query params, JSON body, or form data the same way as HTTPX/requests.
  • Do not pass Pydantic models directly; use .model_dump() or jsonable_encoder.

Async Tests

Use AsyncClient only when the test itself needs to await other coroutines (e.g. querying a real async DB after an API call to verify side effects).

import pytest
from httpx import ASGITransport, AsyncClient
from myapp.main import app

@pytest.mark.anyio
async def test_root_async():
    async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac:
        response = await ac.get("/")
    assert response.status_code == 200

Rules:

  • Mark with @pytest.mark.anyio; register the anyio marker in pyproject.toml.
  • AsyncClient does not trigger lifespan events by default; use asgi-lifespan's LifespanManager when startup/shutdown matters.
  • Instantiate objects that require an event loop (e.g. async DB clients) inside async functions, not at module level.

Dependency Overrides (Preferred Over Mocking)

app.dependency_overrides is the idiomatic FastAPI seam — use it instead of patching internals with unittest.mock.

from fastapi.testclient import TestClient
from myapp.main import app
from myapp.deps import get_current_user

client = TestClient(app)

def fake_user():
    return {"id": 1, "name": "Test User"}

def test_protected_route_with_fake_user():
    app.dependency_overrides[get_current_user] = fake_user
    response = client.get("/me")
    app.dependency_overrides = {}          # always reset after the test
    assert response.status_code == 200
    assert response.json()["name"] == "Test User"

Or reset cleanly with autouse=False fixture teardown:

import pytest
from myapp.main import app

@pytest.fixture()
def override_user():
    app.dependency_overrides[get_current_user] = lambda: {"id": 1, "name": "Test User"}
    yield
    app.dependency_overrides = {}

Rules:

  • Override at the lowest-level dependency that owns the external boundary (e.g. get_db, get_current_user, get_settings).
  • Always reset app.dependency_overrides after each test or fixture teardown.
  • Prefer a real in-process fake (e.g. in-memory SQLite session) over a mock object.

Database Testing

The preferred pattern is a real SQLite (or test Postgres) session injected via dependency override, not a mock.

# tests/conftest.py
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from fastapi.testclient import TestClient
from myapp.main import app
from myapp.db.session import get_db
from myapp.db.models import Base

TEST_DB_URL = "sqlite:///./test.db"

@pytest.fixture(scope="session")
def engine():
    e = create_engine(TEST_DB_URL, connect_args={"check_same_thread": False})
    Base.metadata.create_all(bind=e)
    yield e
    Base.metadata.drop_all(bind=e)

@pytest.fixture()
def db_session(engine):
    Session = sessionmaker(bind=engine)
    session = Session()
    yield session
    session.rollback()
    session.close()

@pytest.fixture()
def client(db_session):
    app.dependency_overrides[get_db] = lambda: db_session
    yield TestClient(app)
    app.dependency_overrides = {}

Rules:

  • Prefer session-scoped engine creation; function-scoped session with rollback per test.
  • Keep unit tests DB-free; use this pattern only in integration-marked tests.
  • For async SQLAlchemy, mirror the same pattern using AsyncEngine / AsyncSession.

Lifespan and Startup Events

TestClient triggers lifespan events (startup/shutdown) when used as a context manager:

def test_with_lifespan():
    with TestClient(app) as client:
        response = client.get("/health")
        assert response.status_code == 200

For AsyncClient, use asgi-lifespan:

from asgi_lifespan import LifespanManager

@pytest.mark.anyio
async def test_with_async_lifespan():
    async with LifespanManager(app):
        async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac:
            response = await ac.get("/health")
    assert response.status_code == 200

Marker Strategy for FastAPI Tests

Marker When to use
unit Pure service/utility logic with no HTTP or DB calls
integration TestClient + real DB session via dependency override
smoke One TestClient call per critical user path, no DB reset
external Tests that call real third-party APIs (skip in CI by default)

Quick Reference: Sending Data

What to send Parameter
Path / query param Part of the URL string
JSON body json={"key": "value"}
Form data data={"field": "value"}
Headers headers={"X-Token": "..."}
Cookies cookies={"session": "..."}
File upload files={"file": ("name.txt", b"content", "text/plain")}

Official Docs