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_overridesswaps the database, auth, and external clients for test doubles without touching production code.TestClientmust run inside awithblock 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_eventis deprecated). - Python 3.11+ so you can use
Annotated[T, Depends(...)]dependency typing. pytest, plushttpx-backedTestClient. For async drivers,aiosqlitegives 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
TestClientacross 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.
TestClientmanages its own loop; do not call it from inside an already-running async test without switching to an async transport such ashttpx.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.
Related Reading
- Up to the topic: Application Factory Patterns.
- Related patterns: Dependency Injection Strategies for what you override, and Configuration Management for the settings the factory consumes.