Dependency Injection
ProTest uses explicit dependency injection. You declare what a test or fixture needs using type annotations.
The Use Marker
Dependencies are declared using Annotated[Type, Use(fixture)]:
from typing import Annotated
from protest import ProTestSession, Use, fixture
session = ProTestSession()
@fixture
def database():
return Database()
@session.test()
async def test_query(db: Annotated[Database, Use(database)]):
result = await db.query("SELECT 1")
assert result == 1
The Use marker takes a function reference, not a string. This makes dependencies explicit and enables IDE navigation.
Type is a hint, not a runtime check
In Annotated[Type, Use(fixture)], Type is a type hint for your IDE and static checkers - ProTest does not validate at runtime that fixture() actually returns a Type. This matches FastAPI's behavior with Annotated[Type, Depends(fn)]: the type is taken on faith, not enforced.
@fixture()
def returns_str() -> str:
return "hello"
@session.test()
def test_mismatch(value: Annotated[int, Use(returns_str)]):
# `value` is actually a `str` at runtime - ProTest will not warn.
# The mismatch surfaces only when `value` is used as an `int`.
...
In practice this is rarely a problem: keep your fixture return types and your call-site annotations aligned, and rely on mypy/pyright for the static check on the fixture itself.
Why Function References?
Using function references instead of string names has benefits:
- IDE support: Go to definition, find usages, refactoring
- No typos: Python raises
NameErrorif you reference a non-existent function - No cycles: A function must be defined before it can be referenced, preventing circular dependencies
# This won't work - Python raises NameError
@session.test()
async def test_bad(x: Annotated[str, Use(undefined_fixture)]):
pass
def undefined_fixture():
return "oops"
Fixtures Using Fixtures
Fixtures can depend on other fixtures:
@fixture
def config():
return {"db_url": "postgres://localhost"}
@fixture
async def database(cfg: Annotated[dict, Use(config)]):
return await connect(cfg["db_url"])
# Bind both to session scope
session.bind(config)
session.bind(database)
@session.test()
async def test_query(db: Annotated[Database, Use(database)]):
# database depends on config, which is resolved first
pass
Resolution Order
ProTest resolves dependencies automatically:
- Analyze the test's parameters
- For each
Use(fixture), recursively resolve that fixture's dependencies - Execute fixtures in dependency order (dependencies first)
- Inject resolved values into the test
Caching
Fixtures are cached according to their scope:
- Session fixtures: Resolved once, reused across all tests
- Suite fixtures: Resolved once per suite
- Function fixtures: Fresh for each test
If two tests both use database, and database is session-scoped, they share the same instance.
Errors
ScopeMismatchError
Raised when a fixture depends on a narrower scope:
@fixture # Test scope (not bound)
def per_test():
return "fresh"
@fixture
def shared(x: Annotated[str, Use(per_test)]):
return x
session.bind(shared) # ERROR: session can't depend on test-scoped
Fix: Either widen the dependency's scope or narrow the dependent fixture's scope.
FixtureError
Raised when a fixture fails during execution. The error wraps the original exception and identifies which fixture failed:
Fixture errors are reported as SETUP ERROR, not test failures. This distinguishes test bugs from infrastructure issues.