Testing a FastAPI app¶
FastAPI apps carry two pieces of mutable state on the app object itself —
dependency_overrides and state — which is fine under pytest's one-test-at-a-time model and
wrong under voci's, where sixteen tests may be reading and writing the same app at once.
voci.fastapi.client swaps both for ContextVar-backed proxies so
each concurrent test gets its own view, and layers it away when the async with exits.
@voci.fixture()
async def api_client(
session: AsyncSession = Depends(session),
settings: Settings = Depends(settings),
) -> AsyncIterator[AsyncClient]:
async with voci_fastapi.client(
app,
overrides={get_session: lambda: session},
state={"settings": settings},
) as client:
yield client
app is the module-level FastAPI() instance from application code, imported unchanged — nothing
about it has to be written for tests. overrides maps a dependency callable to another dependency
callable, the same shape as app.dependency_overrides itself, so get_session resolves to
lambda: session for the lifetime of this fixture and to nothing in particular for a test that
never depended on api_client. ASGITransport sends no lifespan scope, so app's own lifespan
stays out of the way; a test that needs whatever startup builds should depend on
voci.fastapi.lifespan(app) instead, which runs it once for the whole session.
Overriding one dependency for one test¶
api_client is one fixture among the graph everyone shares. A test that needs a different value
for a single dependency writes a sibling fixture rather than mutating the shared one:
@voci.fixture()
def premium_settings() -> Settings:
return Settings(database_url="unused", signup_bonus_cents=5_000, max_orders_per_user=2)
@voci.fixture()
async def premium_client(
session: AsyncSession = Depends(session),
settings: Settings = Depends(premium_settings),
) -> AsyncIterator[AsyncClient]:
async with voci_fastapi.client(
app,
overrides={get_session: lambda: session},
state={"settings": settings},
) as client:
yield client
async def test_premium_signup_grants_credit(
client: AsyncClient = Depends(premium_client),
) -> None:
response = await client.post("/users", json={"email": "frank@example.com"})
assert response.status_code == 201
assert response.json()["credit_cents"] == 5_000
premium_client still depends on the shared session, so it sees the same transaction-per-test
isolation as everything else — only settings changed. Tests using api_client and tests using
premium_client run against the same app at the same time, each reading its own state and its
own overrides.
The full walkthrough is in examples/01-fastapi-crud/tests/fixtures.py (api_client) and
examples/01-fastapi-crud/tests/test_orders.py (premium_client and the tests above it).