Fixtures¶
A fixture is a function decorated with @voci.fixture().
A test or another fixture can request an instance of such fixture by naming Depends(that_function) on a parameter — in its Annotated[...] metadata, or in its default.
The fixture scope decides how widely one instance is shared, from a fresh instance per Depends up to one for the whole suite.
You can auto-use fixtures via voci.use, similar to how a dependency can be declared on a FastAPI router.
voci.fixture
¶
fixture(
*,
scope: Scope = "function",
exclusive: Exclusive = False,
name: str | None = None,
params: Sequence[object] | None = None,
ids: Sequence[str]
| Callable[[object], str | None]
| None = None,
) -> FixtureDecorator
Declare a fixture. Parentheses are always required, even with no arguments.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
scope
|
Scope
|
how widely one constructed instance is shared. See |
'function'
|
exclusive
|
Exclusive
|
|
False
|
name
|
str | None
|
display name in errors, reports, and |
None
|
params
|
Sequence[object] | None
|
case values that multiply every test transitively depending on this fixture,
one collected test per value. The decorated function receives each case through a
parameter literally named |
None
|
ids
|
Sequence[str] | Callable[[object], str | None] | None
|
display id per |
None
|
voci.Depends
¶
Declare an injected parameter: param: Annotated[Type, Depends(fx)], or param: Type =
Depends(fx).
Takes the Fixture object itself, not a name or a bare callable. Typed as returning T so
the parameter's own annotation is checked normally in default position, though at run time it
returns a sentinel; in metadata the declared return type is irrelevant. Raises TypeError
immediately if dependency isn't a Fixture.
voci.Scope
¶
How widely one constructed instance is shared.
"call"— never shared. A fresh instance perDepends(...)site, so a test asking for the same fixture twice gets two distinct values. Teardown still runs at end of test."function"— one instance per test (the default)."module"— one instance per test module."session"— one instance per run.
voci.use
¶
Declare fixtures every test in the enclosing container depends on.
Called as a statement in a module body — a test module, or a package __init__.py, which
reaches every test in that directory and below. Each fixture is constructed before the test's
own dependencies and torn down after them, and its value is never passed to the test. Raises
TypeError for an argument that isn't a Fixture, or for a call anywhere but a module body.
Where the injection is declared¶
A parameter declares its injection in its Annotated[...] metadata, which is the form voci
teaches and generates:
Declaring one parameter both ways — metadata and default — is an error, naming the parameter, even when both name the same fixture.
In metadata the parameter takes no default, so an injected parameter can precede a parameter that
has none — one supplied by @voci.parametrize, say — and calling the test by hand takes an
ordinary Session rather than a sentinel.
An alias carries a marker as well as a signature does, written as a type statement or as a plain
assignment, and it can live in whichever module the suite keeps its fixtures in:
# deps.py
Db = Annotated[Session, Depends(db_fx)]
# test_balance.py
from deps import Db
async def test_balance(db: Db) -> None: ...
A generic alias carries one too: type Repo[T] = Annotated[T, Depends(repo_fx)], named on a
parameter as Repo[Account], injects repo_fx.
voci parses the annotation rather than evaluating it, reading its source text with ast and
evaluating only the Depends(...) calls it finds in metadata. The type half is never evaluated,
so in a module with from __future__ import annotations — where Python does not evaluate it
either — a type imported under if TYPE_CHECKING: is a fine thing to inject against.
What voci does evaluate, it evaluates in the module's globals, which is where the fixture named
in Depends(...) and any alias carrying a marker have to be reachable: a fixture held in a local
variable is not, and voci says so at collection, naming the parameter. An alias imported only
under if TYPE_CHECKING: is out of reach for the same reason, and the parameter naming it is
reported as one nothing can supply.
The short form¶
A parameter can declare the same injection in its default instead:
The two run identically, and this one is shorter to write. Its cost is in what a type checker does
with it when the annotation is dropped along with the default — see
Typing an injected parameter below — and in ruff's B008, which
flags a call in a parameter default and has to be told this one is fine:
Annotated metadata is not a default, so it never trips B008 and needs no such entry.
Typing an injected parameter¶
@voci.fixture() gives a fixture its value type, unwrapping whatever the function yields, awaits
or returns, so Depends(db_fx) is a Session wherever db_fx is a Fixture[Session]. The
parameter that receives it takes its type from its own annotation, and type checkers disagree
about what to do when there is none:
db: Annotated[Session, Depends(db_fx)] |
db: Session = Depends(db_fx) |
db=Depends(db_fx) |
|
|---|---|---|---|
| mypy | Session |
Session |
Any |
| pyright | Session |
Session |
Session |
| pyrefly | Session |
Session |
Session |
Both spellings run the same way, and the short one is a fine thing to write. Its cost is confined
to mypy, and it is larger than one parameter. An injected parameter with no annotation is Any,
so db.no_such_method() and wrong: str = db both pass; and a test whose parameters are all
injected the short way carries no annotation at all, which makes it an untyped function whose body
mypy does not check in the first place. --strict reports the missing annotation and still says
nothing about the body. Writing the annotation gets the body checked under every checker, which is
the reason to write it.
The case values a fixture's params= takes reach the body through a parameter named param, and
nothing checks that the annotation on that parameter agrees with them: a body annotating
param: str over params=[1, 2, 3] type-checks. param is bound by name at construction time,
where the decorator has no say over the signature it is handed.
Fixtures whose value is an iterator¶
A fixture declared -> Iterator[X] reads as one that yields an X, and its injection sites are
typed X. A fixture that returns an iterator declares the same signature, so its sites are typed
X too, while the value they are handed is the iterator — voci dispatches on whether the function
is a generator, not on its annotation. Where the iterator is the value, declare it as
Iterable[X]: that is equally true of an iterator, and it is not one of the shapes
@voci.fixture() unwraps.