FastAPI¶
Voci provides utilities to work with FastAPI applications more easily. FastAPI exposes two mutable objects on the app, which could break under concurrent test execution: state and dependency overrides.
Voci allows you to swap these out for asyncio-aware proxies, which behave exactly the same, but isolate test cases.
You must have fastapi and httpx installed to import these objects.
voci.fastapi.client
async
¶
client(
app: FastAPI,
*,
overrides: Mapping[Override, Override] | None = None,
state: Mapping[str, Any] | None = None,
base_url: str = "http://testserver",
) -> AsyncIterator[AsyncClient]
An httpx.AsyncClient speaking to app in-process, with this test's overrides layered on.
Both the overrides layer and the state layer are pushed even when empty, so an
app.dependency_overrides[...] = ... or app.state.x = ... write inside the async with is
this test's and no one else's — including the one attribute a test never opted into by passing
state=. The app's own state still gets written to, just not from inside a client():
voci.fastapi.lifespan(app) runs outside any client() context, so its writes land with no
layer active and become visible to every test, which is where a lifespan's writes belong.
Nesting stacks: a client() entered while another is active sees the inner mappings first,
then the outer ones, then the app's own — which is what makes a derived fixture that adds one
override to a broader one behave the way it reads.
No lifespan runs; ASGITransport never sends a lifespan scope, and voci does not fake one.
For an app whose startup builds state the tests need, depend on voci.fastapi.lifespan(app).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
app
|
FastAPI
|
your real app — the module-level |
required |
overrides
|
Mapping[Override, Override] | None
|
|
None
|
state
|
Mapping[str, Any] | None
|
|
None
|
base_url
|
str
|
what relative request paths are resolved against. |
'http://testserver'
|
voci.fastapi.lifespan
¶
A session-scoped fixture that runs app's startup and shutdown around the whole run.
client() speaks HTTP scopes only through ASGITransport, so a lifespan that builds an
engine or a connection pool needs a separate fixture, run once per suite rather than once per
test. Depend on this where the app's startup is what puts the state under test in place:
started = voci.fastapi.lifespan(app)
async def test_it(_: FastAPI = Depends(started), c: AsyncClient = Depends(api_client)):
...
Its writes land in the app's own state, since session setup runs outside any test's layer, which is exactly what makes them visible to every test.
Memoised per app, so every call site asking for one app's lifespan gets the same Fixture
object and the app's startup and shutdown run once for the whole suite.
voci.fastapi.uninstall
¶
Put back the dependency_overrides dict and the state object voci found on app.
The memoised lifespan() fixture for app is dropped too, so a later call builds a fresh one.
The proxies go in on the first client() call and outlive it, so this is what a process that
goes on serving the same app after the suite — a notebook, an embedded uvicorn — calls to get
the objects it built back. Idempotent: uninstalling an app voci never installed on, or twice
in a row, does nothing.
The type of both halves of an overrides mapping, and the same callable FastAPI itself keys on:
voci.fastapi.Override
¶
A dependency callable, and what it is overridden by — FastAPI's key and value, unchanged.