FastAPI App Factory Pattern for Testing and Deployment

Key takeaways:

  • A create_app() factory gives every test a clean application instance, eliminating cross-test state leakage.
  • app.dependency_overrides swaps the database, auth, and external clients for test doubles without touching production code.
  • TestClient must run inside a with block so lifespan startup and shutdown actually execute.
  • Deploy the same factory with uvicorn app.main:create_app --factory, so each worker builds its own isolated instance.
  • A health-check endpoint verifies that factory-injected resources initialized correctly.

This guide is the hands-on companion to Application Factory Patterns. If you have not yet adopted a factory, read that page first for the rationale; here we focus on the concrete testing and deployment mechanics.

The Problem This Solves

A module-level app = FastAPI() is constructed once at import. Every test that imports your routers gets that same instance, so an HTTP client, a database session, or a piece of app.state set in one test silently survives into the next. The symptoms are familiar: a test that passes alone but fails in the suite, a ConnectionResetError after an unrelated test, or a mock that "bleeds" across cases. The factory removes the shared instance entirely.

Prerequisites

  • FastAPI on a recent release (lifespan context managers; on_event is deprecated).
  • Python 3.11+ so you can use Annotated[T, Depends(...)] dependency typing.
  • pytest, plus httpx-backed TestClient. For async drivers, aiosqlite gives you a fast in-memory database.

Step-by-Step Implementation

1. Expose a factory with a lifespan

# app/main.py
from contextlib import asynccontextmanager
from collections.abc import AsyncGenerator

from fastapi import FastAPI

from app.config import Settings, get_settings
from app.db import open_pool, close_pool
from app.routers import api_router


@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
    app.state.db_pool = await open_pool(app.state.settings.database_url)
    yield
    await close_pool(app.state.db_pool)


def create_app(settings: Settings | None = None) -> FastAPI:
    cfg = settings or get_settings()
    app = FastAPI(title=cfg.project_name, lifespan=lifespan)
    app.state.settings = cfg
    app.include_router(api_router, prefix="/v1")
    return app

2. Build a fresh app per test

# tests/conftest.py
import pytest
from fastapi.testclient import TestClient

from app.main import create_app
from app.config import Settings
from app.db import get_db_session


def fake_session_factory():
    # Yield a lightweight fake or an in-memory session; no network access in tests.
    yield FakeSession()


@pytest.fixture
def client():
    app = create_app(Settings(environment="test",
                              database_url="sqlite+aiosqlite:///:memory:"))
    app.dependency_overrides[get_db_session] = fake_session_factory
    with TestClient(app) as c:   # Runs lifespan startup, then shutdown on exit.
        yield c
    app.dependency_overrides.clear()   # Critical: stop overrides leaking forward.

3. Write a test that exercises a route

def test_get_user_returns_404_when_missing(client):
    response = client.get("/v1/users/999")
    assert response.status_code == 404
    assert response.json()["error"] == "user_not_found"

4. Deploy the same factory

# --factory tells Uvicorn the target is a callable invoked once per worker.
CMD ["uvicorn", "app.main:create_app", "--factory", \
     "--host", "0.0.0.0", "--port", "8000", "--workers", "4"]

5. Verify wiring with a health check

# app/routers/health.py
from typing import Annotated

from fastapi import APIRouter, Depends
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession

from app.db import get_db_session

router = APIRouter()


@router.get("/health")
async def health(db: Annotated[AsyncSession, Depends(get_db_session)]) -> dict[str, str]:
    await db.execute(text("SELECT 1"))  # Proves the pool from lifespan is live.
    return {"status": "healthy"}

Edge Cases and Gotchas

  • Reusing a single TestClient across modules. A shared client retains cookies and auth headers. Build it per test, or reset state explicitly.
  • Overriding a dependency the route does not use. Overrides only take effect for dependencies actually resolved by the route under test; an override on an unused dependency silently does nothing, which can mask a wiring mistake.
  • Async fixtures without an event loop. TestClient manages its own loop; do not call it from inside an already-running async test without switching to an async transport such as httpx.AsyncClient.

Verification

Run the suite twice — once normally, once with pytest -p no:randomly disabled or with random ordering enabled — and confirm results are identical. Order-dependent failures are the signature of leaked state, which a correct factory plus override cleanup eliminates. For deployment, curl the health endpoint after boot:

curl -fsS http://localhost:8000/health   # Non-zero exit if wiring failed at startup.