Skip to content

Usage with pytest

modern-di-pytest turns DI dependencies into pytest fixtures. It registers no pytest plugin, so nothing loads on its own: you import its two functions in conftest.py or a test module. modern_di_fixture makes one fixture from one dependency, and expose makes one fixture per provider across one or more Group subclasses.

You can test without it. Yield a Container from your own fixture and call container.resolve(...) in the tests; Testing with overrides covers swapping in fakes that way.

How to use

1. Install

uv add --dev modern-di-pytest
pip install modern-di-pytest
poetry add --group dev modern-di-pytest

2. Define a di_container fixture

The package never builds a container. You own it, and you pick its pytest scope:

import typing

import modern_di
import pytest

from app import ioc


@pytest.fixture(scope="session")
def di_container() -> typing.Iterator[modern_di.Container]:
    with modern_di.Container(groups=ioc.ALL_GROUPS) as container:
        container.validate()  # a broken graph errors every test that uses the container
        yield container

If any provider has an async finalizer, make this an async fixture that uses async with. A sync with closes the container through close_sync(), which cannot await, so teardown raises FinalizerError wrapping AsyncFinalizerInSyncCloseError.

3. Materialize dependencies as fixtures

from modern_di_pytest import expose, modern_di_fixture

from app.ioc import Auth, Billing, Dependencies
from app.services import EmailClient


expose(Dependencies, Auth, Billing)  # one fixture per provider, named after the attribute

email_client = modern_di_fixture(EmailClient)  # one fixture, named after this variable

expose installs its fixtures onto the module that calls it, found by stack inspection, so call it at module level in conftest.py or a test module, or pass module= explicitly. Attributes that are not providers are skipped. Before installing anything, it raises TypeError when called with no groups and ValueError when a provider name clashes:

  • Two groups share an attribute name.
  • A provider has the name of the container fixture (di_container, or the container_fixture= you pass).
  • The module already defines the name, for example as your own fixture or an import.

The last two checks are new in modern-di-pytest 4.0.1; earlier releases install the fixture anyway. Call expose() after your own fixtures: a fixture defined after the call replaces the exposed one, and expose() cannot detect that.

An exposed fixture follows pytest's override rules. It replaces a fixture of the same name from pytest, a plugin or a parent conftest for every test under the conftest or module that calls expose(), so a provider named tmp_path replaces pytest's tmp_path. To avoid that, rename the provider, or leave its group out of expose() and create the group's fixtures with modern_di_fixture(), giving the clashing one another name.

4. Use the fixtures in tests

Tests receive resolved dependencies by name:

from app.services import EmailClient, UserService


def test_listing(user_service: UserService) -> None:
    assert user_service.list_users() == []


def test_email(email_client: EmailClient) -> None:
    email_client.send("hi")

Pointing a fixture at a child container

The generated fixtures resolve from di_container, an APP container, so a REQUEST-scoped provider fails at fixture setup with ScopeNotInitializedError. Define a child container fixture and pass its name with container_fixture=:

import typing

import modern_di
import pytest
from modern_di_pytest import modern_di_fixture

from app.services import UserService


@pytest.fixture
def request_container(
    di_container: modern_di.Container,
) -> typing.Iterator[modern_di.Container]:
    with di_container.build_child_container(scope=modern_di.Scope.REQUEST) as container:
        yield container


request_user_service = modern_di_fixture(
    UserService, container_fixture="request_container"
)

expose takes container_fixture= too. Its fixture names come from the group's attributes, so exposing the same group against two containers in one module keeps only the second call's fixtures. Put the two expose calls in separate modules.

A fixture's pytest_scope cannot be wider than its container fixture's scope. A "session" fixture built on the function-scoped request_container fails with pytest's ScopeMismatch.

Overrides

Fixtures resolve during test setup, before the test body runs, so an override set inside the test comes too late. Apply it in a fixture that the generated fixtures depend on. Redefining di_container in a test module does that for every generated fixture in the module, and the with block resets the override after each test:

import typing

import modern_di
import pytest

from app.ioc import Dependencies
from app.services import UserService
from tests.fakes import FakeRepo


@pytest.fixture
def di_container(di_container: modern_di.Container) -> typing.Iterator[modern_di.Container]:
    with di_container.override(Dependencies.user_repo, FakeRepo()):
        yield di_container


def test_with_fake_repo(user_service: UserService) -> None:
    assert user_service.list_users() == ["fake"]

To fake a dependency for some fixtures only, put the override in its own fixture and point those fixtures at it with container_fixture=:

import typing

import modern_di
import pytest
from modern_di_pytest import modern_di_fixture

from app.ioc import Dependencies
from app.services import UserService
from tests.fakes import FakeRepo


@pytest.fixture
def fake_repo_container(
    di_container: modern_di.Container,
) -> typing.Iterator[modern_di.Container]:
    with di_container.override(Dependencies.user_repo, FakeRepo()):
        yield di_container


user_service_with_fake_repo = modern_di_fixture(
    UserService, container_fixture="fake_repo_container"
)


def test_targeted_override(user_service_with_fake_repo: UserService) -> None:
    assert user_service_with_fake_repo.list_users() == ["fake"]

With a session-scoped di_container, watch cached providers (cache=True). A cached instance keeps the dependencies it was built with. If an earlier test resolved it, a later override of one of its dependencies does not reach it; if it was first resolved under an override, it keeps the fake after the override ends. Override the cached provider itself, or give the tests that need the fake a function-scoped container.

For transactional database sessions and other patterns, see Testing with overrides.

See also

API

Symbol Description
modern_di_fixture(dependency, *, container_fixture="di_container", name=None, pytest_scope="function") Returns a pytest fixture that resolves dependency (a type or a provider) through container.resolve_dependency on the container fixture. Assign it to a module-level name; name= overrides the fixture name.
expose(*groups, container_fixture="di_container", pytest_scope="function", module=None) Installs one fixture per provider attribute of each group onto the calling module, or onto module=. Raises ValueError on a duplicate name across groups, a provider named like the container fixture, or a name the module already defines, and TypeError with no groups, before installing anything.