Testing Patterns¶
Recipes for testing dioxide applications with fakes instead of mocks.
Recipe: Container Fixture¶
Problem¶
You want complete test isolation with a fresh container for each test.
Solution¶
Create a pytest fixture that provides a fresh container instance per test.
Code¶
"""Fresh container per test - the recommended pattern."""
import pytest
from dioxide import Container, Profile
@pytest.fixture
async def container():
"""Fresh container with complete test isolation.
Each test gets:
- Clean singleton cache (no state from previous tests)
- Fresh adapter instances
- Automatic lifecycle management
"""
async with Container(profile=Profile.TEST) as c:
yield c
# Cleanup happens automatically
@pytest.fixture
def sync_container():
"""Synchronous version for non-async tests."""
return Container(profile=Profile.TEST)
# Usage in tests
async def test_user_registration(container):
"""Each test gets isolated container."""
service = container.resolve(UserService)
result = await service.register("alice@example.com")
assert result is not None
Explanation¶
Fresh per test: Each test gets a new
Container()instanceComplete isolation: No singleton state bleeds between tests
Lifecycle managed:
async with chandles initialization and cleanupAsync fixture: Use
async deffor fixtures that need lifecycle
Recipe: Typed Fake Access¶
Problem¶
You want IDE autocomplete and type checking when accessing fake adapters in tests.
Solution¶
Create typed fixtures that cast the resolved adapter to the fake type.
Code¶
"""Typed fixtures for IDE-friendly fake access."""
from typing import TYPE_CHECKING
import pytest
from dioxide import Container, Profile
from app.domain.ports import EmailPort, UserRepository
if TYPE_CHECKING:
from app.adapters.fakes import FakeEmailAdapter, FakeUserRepository
@pytest.fixture
async def container():
"""Fresh container per test."""
async with Container(profile=Profile.TEST) as c:
yield c
@pytest.fixture
def fake_email(container) -> "FakeEmailAdapter":
"""Typed access to fake email adapter.
Provides autocomplete for:
- fake_email.sent_emails
- fake_email.clear()
- fake_email.was_welcome_email_sent_to(email)
"""
return container.resolve(EmailPort) # type: ignore[return-value]
@pytest.fixture
def fake_users(container) -> "FakeUserRepository":
"""Typed access to fake user repository.
Provides autocomplete for:
- fake_users.users
- fake_users.seed(user1, user2)
"""
return container.resolve(UserRepository) # type: ignore[return-value]
# Usage with full IDE support
async def test_welcome_email_sent(container, fake_email, fake_users):
"""IDE knows fake_email has .sent_emails attribute."""
# Seed test data
fake_users.seed({"id": 1, "name": "Alice", "email": "alice@example.com"})
# Run service
service = container.resolve(NotificationService)
await service.send_welcome(1)
# Verify with autocomplete
assert len(fake_email.sent_emails) == 1
assert fake_email.was_welcome_email_sent_to("alice@example.com")
Explanation¶
TYPE_CHECKING import: Only import fake types during type checking, not runtime
String annotation: Use quotes for forward reference to avoid import issues
Type ignore: Tell type checker the cast is intentional
IDE support: Now you get autocomplete for fake-specific methods
Recipe: Async Test Setup¶
Problem¶
You need to test async services with pytest.
Solution¶
Use pytest-asyncio with async fixtures and test functions.
Code¶
"""Async testing with pytest-asyncio."""
import pytest
from dioxide import Container, Profile
# In pyproject.toml:
# [tool.pytest.ini_options]
# asyncio_mode = "auto"
@pytest.fixture
async def container():
"""Async fixture with lifecycle management."""
c = Container()
c.scan(profile=Profile.TEST)
async with c:
yield c
@pytest.fixture
async def service(container):
"""Resolve async service."""
return container.resolve(UserService)
# Test functions can be async
async def test_async_operation(service, fake_email):
"""Test async service method."""
await service.register_user("Alice", "alice@example.com")
assert len(fake_email.sent_emails) == 1
# Class-based async tests
class DescribeUserService:
"""Tests for UserService."""
async def it_registers_new_user(self, service, fake_users):
"""Creates user in repository."""
result = await service.register_user("Bob", "bob@example.com")
assert result["name"] == "Bob"
assert len(fake_users.users) == 1
async def it_sends_welcome_email(self, service, fake_email):
"""Sends welcome email on registration."""
await service.register_user("Carol", "carol@example.com")
assert fake_email.was_welcome_email_sent_to("carol@example.com")
Explanation¶
asyncio_mode = “auto”: Automatically handles async tests
Async fixtures: Use
async deffor fixtures that need awaitMixed sync/async: Sync fixtures work with async tests
Class methods: Async methods in test classes work too
Recipe: Error Injection¶
Problem¶
You want to test error handling when adapters fail.
Solution¶
Create fakes with configurable error behavior.
Code¶
"""Fakes with configurable error injection."""
from typing import Protocol
from dioxide import Profile, adapter
class PaymentPort(Protocol):
async def charge(self, amount: float) -> dict: ...
class PaymentError(Exception):
"""Payment processing error."""
pass
@adapter.for_(PaymentPort, profile=Profile.TEST)
class FakePaymentAdapter:
"""Fake payment with error injection."""
def __init__(self):
self.charges: list[dict] = []
self.should_fail = False
self.failure_reason = "Card declined"
async def charge(self, amount: float) -> dict:
if self.should_fail:
raise PaymentError(self.failure_reason)
charge = {"id": f"ch_{len(self.charges) + 1}", "amount": amount}
self.charges.append(charge)
return charge
# Test helpers
def fail_next(self, reason: str = "Card declined") -> None:
"""Make next charge fail."""
self.should_fail = True
self.failure_reason = reason
def reset(self) -> None:
"""Reset to success mode."""
self.should_fail = False
self.charges.clear()
# Test error handling
import pytest
@pytest.fixture
def fake_payment(container) -> FakePaymentAdapter:
adapter = container.resolve(PaymentPort)
yield adapter
adapter.reset() # Clean up after test
class DescribeCheckoutService:
"""Tests for CheckoutService error handling."""
async def it_raises_on_payment_failure(self, container, fake_payment):
"""Propagates payment errors."""
fake_payment.fail_next("Insufficient funds")
service = container.resolve(CheckoutService)
with pytest.raises(PaymentError) as exc_info:
await service.checkout(amount=100.0)
assert "Insufficient funds" in str(exc_info.value)
assert len(fake_payment.charges) == 0
async def it_retries_on_transient_failure(self, container, fake_payment):
"""Retries once on network error."""
# First call fails, second succeeds
call_count = 0
original_charge = fake_payment.charge
async def charge_with_retry(amount: float) -> dict:
nonlocal call_count
call_count += 1
if call_count == 1:
raise PaymentError("Network timeout")
return await original_charge(amount)
fake_payment.charge = charge_with_retry
service = container.resolve(CheckoutService)
result = await service.checkout(amount=50.0)
assert result is not None
assert call_count == 2
Explanation¶
Configurable failure:
should_failflag controls behaviorFailure reason: Customizable error message for different scenarios
Reset after test: Fixture cleanup prevents state leakage
Test isolation: Each test controls its own failure conditions
Recipe: Controllable Time¶
Problem¶
You need to test time-dependent logic (throttling, expiration, etc.) deterministically.
Solution¶
Create a fake clock that you can control in tests.
Code¶
"""Fake clock for time-dependent tests."""
from datetime import datetime, timedelta, UTC
from typing import Protocol
from dioxide import Profile, adapter
class Clock(Protocol):
def now(self) -> datetime: ...
@adapter.for_(Clock, profile=Profile.PRODUCTION)
class SystemClock:
"""Real system clock for production."""
def now(self) -> datetime:
return datetime.now(UTC)
@adapter.for_(Clock, profile=Profile.TEST)
class FakeClock:
"""Controllable fake clock for testing."""
def __init__(self):
self._now = datetime(2024, 1, 1, 12, 0, 0, tzinfo=UTC)
def now(self) -> datetime:
return self._now
# Test control methods
def set_time(self, dt: datetime) -> None:
"""Set current time."""
self._now = dt
def advance(self, **kwargs) -> None:
"""Advance time by timedelta kwargs (days, hours, minutes, etc.)."""
self._now += timedelta(**kwargs)
def rewind(self, **kwargs) -> None:
"""Rewind time by timedelta kwargs."""
self._now -= timedelta(**kwargs)
# Test time-dependent logic
import pytest
@pytest.fixture
def fake_clock(container) -> FakeClock:
return container.resolve(Clock)
class DescribeThrottling:
"""Tests for rate limiting logic."""
async def it_allows_first_request(self, container, fake_clock):
"""First request within window succeeds."""
fake_clock.set_time(datetime(2024, 1, 1, tzinfo=UTC))
service = container.resolve(RateLimitedService)
result = await service.send_notification(user_id=1)
assert result is True
async def it_blocks_request_within_cooldown(self, container, fake_clock):
"""Request within 1-hour cooldown is blocked."""
fake_clock.set_time(datetime(2024, 1, 1, 12, 0, tzinfo=UTC))
service = container.resolve(RateLimitedService)
await service.send_notification(user_id=1)
# Only 30 minutes later
fake_clock.advance(minutes=30)
result = await service.send_notification(user_id=1)
assert result is False
async def it_allows_request_after_cooldown(self, container, fake_clock):
"""Request after cooldown period succeeds."""
fake_clock.set_time(datetime(2024, 1, 1, 12, 0, tzinfo=UTC))
service = container.resolve(RateLimitedService)
await service.send_notification(user_id=1)
# 2 hours later (past 1-hour cooldown)
fake_clock.advance(hours=2)
result = await service.send_notification(user_id=1)
assert result is True
Explanation¶
Inject Clock as port: Services depend on
Clockprotocol, notdatetime.now()FakeClock is controllable:
set_time()andadvance()for precise controlDeterministic tests: Same time values every run, no flaky failures
Natural test flow: Test reads like a story with time progression
Recipe: Seeding Test Data¶
Problem¶
You want to set up test data in fake repositories before running tests.
Solution¶
Add seed() methods to fake adapters for convenient test setup.
Code¶
"""Test data seeding patterns."""
from typing import Protocol
from dioxide import Profile, adapter
class UserRepository(Protocol):
async def find(self, user_id: int) -> dict | None: ...
async def save(self, user: dict) -> dict: ...
@adapter.for_(UserRepository, profile=Profile.TEST)
class FakeUserRepository:
"""Fake repository with seeding helpers."""
def __init__(self):
self.users: dict[int, dict] = {}
self._next_id = 1
async def find(self, user_id: int) -> dict | None:
return self.users.get(user_id)
async def save(self, user: dict) -> dict:
if "id" not in user:
user["id"] = self._next_id
self._next_id += 1
self.users[user["id"]] = user
return user
# Test helpers
def seed(self, *users: dict) -> None:
"""Seed multiple users at once."""
for user in users:
if "id" not in user:
user["id"] = self._next_id
self._next_id += 1
self.users[user["id"]] = user
def clear(self) -> None:
"""Clear all users."""
self.users.clear()
self._next_id = 1
# Convenient fixtures for common test scenarios
import pytest
@pytest.fixture
def fake_users(container) -> FakeUserRepository:
repo = container.resolve(UserRepository)
yield repo
repo.clear()
@pytest.fixture
def alice(fake_users) -> dict:
"""Standard test user: Alice."""
user = {"id": 1, "name": "Alice", "email": "alice@example.com"}
fake_users.seed(user)
return user
@pytest.fixture
def bob(fake_users) -> dict:
"""Standard test user: Bob."""
user = {"id": 2, "name": "Bob", "email": "bob@example.com"}
fake_users.seed(user)
return user
@pytest.fixture
def many_users(fake_users) -> list[dict]:
"""10 test users for pagination tests."""
users = [
{"name": f"User{i}", "email": f"user{i}@example.com"}
for i in range(1, 11)
]
fake_users.seed(*users)
return list(fake_users.users.values())
# Usage
async def test_find_user(container, alice):
"""Find seeded user."""
service = container.resolve(UserService)
result = await service.get_user(alice["id"])
assert result["name"] == "Alice"
async def test_pagination(container, many_users):
"""Test with multiple users."""
service = container.resolve(UserService)
page = await service.list_users(limit=5, offset=0)
assert len(page) == 5
Explanation¶
seed() method: Convenient way to add multiple entities at once
Auto-ID generation: Fake handles ID assignment like real database
Fixture composition: Build complex test data from simple fixtures
Named fixtures:
alice,bobare more readable than inline dicts
Recipe: Parametrized Tests¶
Problem¶
You want to test multiple scenarios without duplicating test code.
Solution¶
Use pytest’s parametrize decorator with clear test case names.
Code¶
"""Parametrized tests for multiple scenarios."""
import pytest
from datetime import datetime, UTC
@pytest.mark.parametrize(
"days_since_last_sent,expected_result",
[
pytest.param(None, True, id="never-sent-before"),
pytest.param(0, False, id="sent-today"),
pytest.param(7, False, id="sent-last-week"),
pytest.param(29, False, id="sent-29-days-ago"),
pytest.param(30, True, id="sent-exactly-30-days-ago"),
pytest.param(60, True, id="sent-60-days-ago"),
],
)
async def test_email_throttling(
container,
fake_users,
fake_clock,
days_since_last_sent,
expected_result,
):
"""Email throttling respects 30-day cooldown."""
# Set up user with last_sent timestamp
now = datetime(2024, 6, 1, tzinfo=UTC)
fake_clock.set_time(now)
if days_since_last_sent is not None:
last_sent = now - timedelta(days=days_since_last_sent)
else:
last_sent = None
fake_users.seed({
"id": 1,
"email": "alice@example.com",
"last_email_sent": last_sent,
})
service = container.resolve(NotificationService)
result = await service.send_welcome(user_id=1)
assert result == expected_result
@pytest.mark.parametrize(
"input_email,is_valid",
[
pytest.param("user@example.com", True, id="valid-email"),
pytest.param("user@sub.example.com", True, id="valid-subdomain"),
pytest.param("user.name@example.com", True, id="valid-dot-local"),
pytest.param("invalid", False, id="no-at-sign"),
pytest.param("@example.com", False, id="no-local-part"),
pytest.param("user@", False, id="no-domain"),
pytest.param("", False, id="empty-string"),
],
)
def test_email_validation(input_email, is_valid):
"""Email validation catches invalid formats."""
from app.domain.validators import is_valid_email
assert is_valid_email(input_email) == is_valid
Explanation¶
pytest.param with id: Named test cases for clear output
Descriptive IDs:
sent-29-days-agovs anonymous parametersTable-driven: Easy to add new cases
Single assertion: Each parametrized run tests one thing
See Also¶
TESTING_GUIDE.md - Comprehensive testing philosophy
FastAPI Integration - Testing FastAPI endpoints
Database Patterns - Repository fakes