Skip to content

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 Scope.

'function'
exclusive Exclusive

True, or a string token naming a contended resource. Tests transitively depending on it never run concurrently with each other.

False
name str | None

display name in errors, reports, and --durations. Defaults to fn.__name__.

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 param -- voci has no request object, so this is the same name-based convention @voci.parametrize uses for its own call kwargs.

None
ids Sequence[str] | Callable[[object], str | None] | None

display id per params entry: a same-length sequence of strings, or a callable taking one value and returning a str (or None, to fall back to the automatic id for that value). Only valid alongside params.

None

voci.Depends

Depends(dependency: Fixture[T]) -> T

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

Scope = Literal['call', 'function', 'module', 'session']

How widely one constructed instance is shared.

  • "call" — never shared. A fresh instance per Depends(...) 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

use(*fixtures: Fixture[Any]) -> None

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:

async def test_balance(db: Annotated[Session, Depends(db_fx)]) -> None: ...

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:

async def test_balance(db: Session = Depends(db_fx)) -> None: ...

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:

[tool.ruff.lint.flake8-bugbear]
extend-immutable-calls = ["voci.Depends"]

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.