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