Skip to content

Testing

The point of wiring by type is that a test can substitute any node of the graph without the code under test noticing.

Overriding a provider

override() selects a key, and using() replaces it for the duration of a block, everywhere it appears — including deep in the graph, as a dependency of something else:

>>> from depin import Container
>>> class Clock:
...     def now(self) -> str:
...         return 'real'
>>> class Report:
...     def __init__(self, clock: Clock) -> None:
...         self.clock = clock
...
...     def render(self) -> str:
...         return f'at {self.clock.now()}'
>>> class FrozenClock:
...     def now(self) -> str:
...         return '2026-01-01'
>>> di = Container().bind(Clock).bind(Report).freeze()
>>> with di.override(Clock).using(FrozenClock()):
...     di[Report].render()
'at 2026-01-01'

Three details worth knowing:

  • If the replacement is a callable that is not a class, it is treated as a factory and invoked per resolution. Anything else is returned as-is.
  • The override is bound to the current contextvars.Context, so concurrent tests do not leak into each other.
  • Overrides nest, innermost wins, and a key that was never bound at all can be overridden — useful for a dependency you only stub.

What survives an override

override() replaces the key immediately, even for a singleton already built. What survives is a consumer — a value built before the block keeps the instance it was constructed with, because a provider's arguments are resolved once, at construction time:

>>> di = Container().bind(Clock).bind(Report).freeze()
>>> _ = di[Report]
>>> with di.override(Clock).using(FrozenClock()):
...     di[Clock].now()
...     di[Report].render()
'2026-01-01'
'at real'

reset() drops every built singleton's cache, so the next resolution of Report rebuilds it and picks up the override that is active at that point:

>>> di.reset()
>>> with di.override(Clock).using(FrozenClock()):
...     di.reset()
...     di[Report].render()
'at 2026-01-01'
>>> di.reset()
>>> di[Report].render()
'at real'

The depin.ext.pytest fixtures below call reset() on both edges of the block, so a test never has to reason about which singletons in the root cache were built first. A scoped value already cached in an open depin_scope frame is outside that cache: neither edge evicts it, so it keeps the dependency it was built with for as long as the scope stays open.

A pytest fixture

Freezing is cheap and gives every test an isolated cache:

import pytest

from myapp.wiring import build


@pytest.fixture
def di():
    container = build()
    yield container
    container.close()


def test_report_uses_the_clock(di):
    with di.override(Clock).using(FrozenClock()):
        assert di[Report].render() == 'at 2026-01-01'

For an async graph use aclose() in the teardown half of the fixture.

The pytest plugin

depin.ext.pytest is registered on the pytest11 entry point by the distribution, so installing pydepin at all makes its fixtures available in any suite with no conftest.py import. The pytest extra adds nothing to that: it only states the pytest floor the plugin is tested against.

uv add pydepin                # the fixtures are already available
uv add 'pydepin[pytest]'      # and the tested pytest floor is enforced

The plugin defines depin_container itself, but only to raise: a suite that never hands it a container gets a ContainerNotBoundError naming the fixture to define. Add one to your own conftest.py; every other fixture builds on whatever it returns:

import pytest

from depin import FrozenContainer

from myapp.wiring import build


@pytest.fixture
def depin_container() -> FrozenContainer:
    return build()

depin_override is depin_container's override() with the fix from the note above already applied — it calls reset() before entering the block and again on exit:

def test_report_uses_the_fake_clock(depin_container, depin_override) -> None:
    # Report is a singleton and is already built here, before the override.
    real = depin_container[Report]

    with depin_override(Clock, FrozenClock()) as di:
        assert di[Report].render() == 'at 2026-01-01'

    # Report is a singleton in the root cache, so reset() on exit rebuilds it
    # against the real Clock the next time it is resolved.
    assert depin_container[Report].render() == 'at real'

Use depin_aoverride — the same shape, built on areset() — when a singleton on the overridden path is constructed by an async provider; reset() raises for that case rather than leaving it half torn down.

depin_scope opens one synchronous scope around the test and yields its ScopeFrame, so a Container.scope_value key can be seeded before anything resolves through it:

def test_job_sees_the_seeded_value(depin_container, depin_scope) -> None:
    depin_scope.provide(job, 'reindex')
    assert depin_container.resolve(job) == 'reindex'

depin_ascope is its async counterpart, built on Host.ascope().

One test may request depin_scope and depin_override together. Both edges of the override block reach the root cache only, so a scoped value already built inside the open scope is untouched and keeps the dependency it was constructed with.

examples/eviction/ in the repository runs the same eviction without pytest, as a plain module.

Replacing bindings instead of overriding them

When a whole subsystem differs in tests — an in-memory store instead of a database — swap the registry rather than each provider:

from depin import Container

from myapp.wiring import services
from tests.fakes import fake_infra


def build_test_container():
    return Container(fake_infra, services).freeze()

This keeps the production graph untouched and still exercises the real Container / FrozenContainer, which is what you want a wiring test to prove.

Wiring a function under test

@inject fills only the parameters whose default is injected, and leaves everything else to the caller — including those same parameters, if the caller passes them:

>>> from depin import Container, injected
>>> class Repo:
...     def count(self) -> int:
...         return 3
>>> class FakeRepo:
...     def count(self) -> int:
...         return 99
>>> di = Container().bind(Repo).freeze()
>>> @di.inject
... def summary(label: str, repo: Repo = injected) -> str:
...     return f'{label}={repo.count()}'
>>> summary(label='n')
'n=3'
>>> summary(label='n', repo=FakeRepo())
'n=99'

The key comes from the parameter's annotation, in the grammar a provider's parameters already use: Annotated[Cache, Tag('primary')] selects a tagged binding, Annotated[str, Named(db_url)] a named one, and Metrics | None an optional dependency, filled with None when nothing provides it.

Injected keys are validated when the decorator runs, not when the function is called: decorating a function that asks for an unregistered key raises MissingProviderError immediately, at import time, instead of on the first request.