How to Test Components¶
Problem¶
You want to write fast, isolated tests for pico-ioc components without starting real databases, network services, or other heavy infrastructure.
Solution¶
pico-ioc provides three primary mechanisms for testing:
overrides-- replace any provider with a mock or stub.profiles-- activate test-specific conditional components.DictSource/FlatDictSource-- provide test configuration inline.
1. Override components with mocks¶
Pass overrides={Key: replacement} to init(). The replacement can be an instance, a callable, or a (callable, lazy) tuple:
import pytest
from pico_ioc import init, component
# Production code
@component
class Database:
def query(self, sql: str) -> list:
return real_db_query(sql)
@component
class UserService:
def __init__(self, db: Database):
self.db = db
def get_users(self) -> list:
return self.db.query("SELECT * FROM users")
# Test code
class FakeDatabase:
def query(self, sql: str) -> list:
return [{"id": 1, "name": "Alice"}]
def test_get_users():
container = init(
modules=[__name__],
overrides={Database: FakeDatabase()},
)
svc = container.get(UserService)
users = svc.get_users()
assert users == [{"id": 1, "name": "Alice"}]
container.shutdown()
2. Use unittest.mock for fine-grained control¶
For tests that need to assert call counts, arguments, or side effects:
from unittest.mock import MagicMock
def test_user_service_calls_db():
mock_db = MagicMock(spec=Database)
mock_db.query.return_value = [{"id": 1}]
container = init(
modules=[__name__],
overrides={Database: mock_db},
)
svc = container.get(UserService)
svc.get_users()
mock_db.query.assert_called_once_with("SELECT * FROM users")
container.shutdown()
3. Test with inline configuration¶
Use DictSource for tree-based config or FlatDictSource for flat key-value config:
from dataclasses import dataclass
from pico_ioc import init, configured, configuration, DictSource
@configured(prefix="db")
@dataclass
class DbConfig:
host: str = "localhost"
port: int = 5432
def test_config_injection():
cfg = configuration(
DictSource({"db": {"host": "testdb", "port": 9999}})
)
container = init(modules=[__name__], config=cfg)
db_cfg = container.get(DbConfig)
assert db_cfg.host == "testdb"
assert db_cfg.port == 9999
container.shutdown()
4. Use profiles for test-specific implementations¶
Register test doubles that only activate under a "test" profile:
from pico_ioc import component
@component(conditional_profiles=("test",))
class InMemoryDatabase:
"""Only registered when the 'test' profile is active."""
def query(self, sql: str) -> list:
return []
def test_with_profile():
container = init(
modules=[__name__],
profiles=("test",),
)
db = container.get(InMemoryDatabase)
assert db.query("SELECT 1") == []
container.shutdown()
5. Validate wiring without running the app¶
Use validate_only=True to verify all bindings and detect cycles without actually creating singletons:
def test_wiring_is_valid():
container = init(
modules=["myapp"],
validate_only=True,
)
# If we get here, all bindings are valid
container.shutdown()
6. Pytest fixture pattern¶
Wrap container creation in a fixture for automatic cleanup:
import pytest
@pytest.fixture
def container():
c = init(
modules=["myapp"],
overrides={Database: FakeDatabase()},
config=configuration(DictSource({"db": {"host": "testdb"}})),
)
yield c
c.shutdown()
def test_service(container):
svc = container.get(UserService)
assert svc.get_users() is not None
7. Async test pattern¶
For components with __ainit__ or async @configure:
import pytest
@pytest.fixture
async def container():
c = init(
modules=["myapp"],
overrides={Database: FakeDatabase()},
)
yield c
await c.ashutdown()
@pytest.mark.asyncio
async def test_async_service(container):
svc = await container.aget(AsyncService)
result = await svc.process()
assert result == "ok"
Explanation¶
The overrides parameter in init() directly replaces the provider for a given key in the ComponentFactory. This means:
- The override is used instead of the scanned
@componentor@provides. - All downstream dependents receive the override.
- Overrides take highest precedence and bypass conditional logic.
When you pass a plain value (not a callable), pico-ioc wraps it in lambda: value. When you pass a callable, it's called at resolution time with lambda: callable().
Common Pitfalls¶
| Pitfall | Fix |
|---|---|
Forgetting container.shutdown() -- resources leak between tests. | Use a fixture with yield + shutdown(). |
| Overriding with a class instead of an instance -- the override is the class object, not an instance of it. | Pass overrides={Key: MyFake()} (with parentheses). |
| Shared mutable state between tests -- singletons persist. | Create a fresh container per test. |
Async component in sync test -- AsyncResolutionError. | Use pytest.mark.asyncio and await container.aget(...). |
Missing modules in init() -- test cannot find overridden dependencies' dependents. | Include all modules needed by the component under test. |