Testing Patterns¶
This page catalogs common patterns for writing effective fakes and tests with dioxide.
Writing Effective Fakes¶
Fakes should be simple, fast, and deterministic. Here are patterns for writing effective fakes.
Simple In-Memory Fakes¶
The most common pattern: store data in memory instead of database.
from typing import Protocol
from dioxide import adapter, Profile
# Port
class UserRepository(Protocol):
async def find_by_id(self, user_id: int) -> dict | None: ...
async def create(self, name: str, email: str) -> dict: ...
async def update(self, user: dict) -> None: ...
async def delete(self, user_id: int) -> None: ...
# Fake implementation
@adapter.for_(UserRepository, profile=Profile.TEST)
class FakeUserRepository:
"""In-memory user repository for testing."""
def __init__(self):
self.users: dict[int, dict] = {}
self._next_id = 1
async def find_by_id(self, user_id: int) -> dict | None:
return self.users.get(user_id)
async def create(self, name: str, email: str) -> dict:
user = {
"id": self._next_id,
"name": name,
"email": email,
}
self.users[self._next_id] = user
self._next_id += 1
return user
async def update(self, user: dict) -> None:
if user["id"] in self.users:
self.users[user["id"]] = user
async def delete(self, user_id: int) -> None:
self.users.pop(user_id, None)
# Test-only helper (not in protocol!)
def seed(self, *users: dict) -> None:
"""Seed with test data."""
for user in users:
self.users[user["id"]] = user
Key points:
Simple dict storage
Auto-incrementing ID
Implements all protocol methods
Test-only
seed()helper for test setup
Fakes with Verification¶
For services that produce side effects (email, logging, events), capture calls for verification.
from typing import Protocol
from dioxide import adapter, Profile
# Port
class EmailPort(Protocol):
async def send(self, to: str, subject: str, body: str) -> None: ...
# Fake with verification
@adapter.for_(EmailPort, profile=Profile.TEST)
class FakeEmailAdapter:
"""Fake email that captures sends for verification."""
def __init__(self):
self.sent_emails = [] # Record all sends
async def send(self, to: str, subject: str, body: str) -> None:
self.sent_emails.append({
"to": to,
"subject": subject,
"body": body,
})
# Test-only helpers (not in protocol!)
def verify_sent_to(self, email: str) -> bool:
"""Check if email was sent to address."""
return any(e["to"] == email for e in self.sent_emails)
def verify_subject_contains(self, text: str) -> bool:
"""Check if any email subject contains text."""
return any(text in e["subject"] for e in self.sent_emails)
def clear(self) -> None:
"""Clear sent emails (for test isolation)."""
self.sent_emails = []
Usage in tests:
from dioxide import Container, Profile
async def test_welcome_email_sent(container: Container):
service = container.resolve(UserService)
await service.register_user("Alice", "alice@example.com")
# Natural verification
email = container.resolve(EmailPort)
assert email.verify_sent_to("alice@example.com")
assert email.verify_subject_contains("Welcome")
Controllable Fakes¶
For testing time-dependent logic, make fakes controllable.
from datetime import datetime, UTC
from typing import Protocol
from dioxide import adapter, Profile
# Port
class Clock(Protocol):
def now(self) -> datetime: ...
# Controllable fake
@adapter.for_(Clock, profile=Profile.TEST)
class FakeClock:
"""Controllable fake clock for testing time logic."""
def __init__(self):
self._now = datetime(2024, 1, 1, tzinfo=UTC)
def now(self) -> datetime:
return self._now
# Test-only control methods
def set_time(self, dt: datetime) -> None:
"""Set current time."""
self._now = dt
def advance(self, **kwargs) -> None:
"""Advance time by delta."""
from datetime import timedelta
self._now += timedelta(**kwargs)
Usage in tests:
from datetime import datetime, timedelta, UTC
from dioxide import Container
async def test_throttles_within_30_days(container: Container):
clock = container.resolve(Clock)
users = container.resolve(UserRepository)
service = container.resolve(NotificationService)
# Set initial time
clock.set_time(datetime(2024, 1, 1, tzinfo=UTC))
# First send succeeds
users.seed({"id": 1, "email": "alice@example.com", "last_sent": None})
result = await service.send_welcome(1)
assert result is True
# Advance 14 days
clock.advance(days=14)
# Second send is throttled
result = await service.send_welcome(1)
assert result is False # Throttled!
# Advance 20 more days (total 34 days)
clock.advance(days=20)
# Third send succeeds
result = await service.send_welcome(1)
assert result is True
Fakes with Error Injection¶
For testing error handling, make fakes configurable to fail.
from typing import Protocol
from dioxide import adapter, Profile
# Port
class PaymentGateway(Protocol):
async def charge(self, amount: float, card: str) -> dict: ...
# Define custom exception
class PaymentError(Exception):
"""Payment processing error."""
pass
# Fake with error injection
@adapter.for_(PaymentGateway, profile=Profile.TEST)
class FakePaymentGateway:
"""Fake payment gateway with error injection."""
def __init__(self):
self.charges = []
self.should_fail = False
self.failure_reason = "Card declined"
async def charge(self, amount: float, card: str) -> dict:
if self.should_fail:
raise PaymentError(self.failure_reason)
charge = {
"id": f"ch_{len(self.charges) + 1}",
"amount": amount,
"card": card,
"status": "succeeded",
}
self.charges.append(charge)
return charge
# Test-only control
def fail_next_charge(self, reason: str = "Card declined") -> None:
"""Make next charge fail."""
self.should_fail = True
self.failure_reason = reason
def reset(self) -> None:
"""Clear state between tests."""
self.charges = []
self.should_fail = False
self.failure_reason = "Card declined"
Usage in tests:
import pytest
from dioxide import Container
async def test_payment_failure_handling(container: Container):
gateway = container.resolve(PaymentGateway)
service = container.resolve(CheckoutService)
# Configure fake to fail
gateway.fail_next_charge(reason="Insufficient funds")
# Test error handling
with pytest.raises(PaymentError) as exc_info:
await service.checkout(cart_id=123, card="4242424242424242")
assert "Insufficient funds" in str(exc_info.value)
Common Test Patterns¶
Pattern 1: Reset Pattern¶
Clear fake state between tests for isolation.
# conftest.py
import pytest
@pytest.fixture
def fake_email(container):
adapter = container.resolve(EmailPort)
yield adapter
adapter.clear() # Reset after each test
# Or use fresh container per test
@pytest.fixture
def container():
c = Container()
c.scan(profile=Profile.TEST)
return c # Fresh container = fresh fakes
Pattern 2: Verification Pattern¶
Check fake calls in natural, readable way.
async def test_sends_email(fake_email, service):
await service.register_user("Alice", "alice@example.com")
# Natural verification
assert len(fake_email.sent_emails) == 1
assert fake_email.sent_emails[0]["to"] == "alice@example.com"
# Or with helper
assert fake_email.verify_sent_to("alice@example.com")
Pattern 3: Fixture Pattern¶
Use pytest fixtures for clean test setup.
# conftest.py
@pytest.fixture
def alice_user(fake_users):
"""Seed a test user named Alice."""
fake_users.seed({
"id": 1,
"name": "Alice",
"email": "alice@example.com",
})
return 1 # Return user ID
# Test uses fixture
async def test_sends_to_alice(alice_user, service, fake_email):
await service.send_welcome_email(alice_user)
assert fake_email.sent_emails[0]["to"] == "alice@example.com"
Pattern 4: Async Pattern¶
Testing async code is straightforward with pytest-asyncio.
# Install: pip install pytest-asyncio
# pyproject.toml:
# [tool.pytest.ini_options]
# asyncio_mode = "auto"
# Tests can be async
async def test_async_operation(container):
service = container.resolve(AsyncService)
result = await service.do_something()
assert result is not None
Pattern 5: Parametrization Pattern¶
Test multiple scenarios without duplication.
import pytest
from datetime import datetime, UTC
@pytest.mark.parametrize("days_elapsed,should_send", [
(0, True), # Never sent before
(14, False), # Too soon (14 days)
(29, False), # Still too soon (29 days)
(30, True), # Exactly 30 days
(35, True), # More than 30 days
])
async def test_throttling(
days_elapsed,
should_send,
notification_service,
fake_users,
fake_clock,
):
# Arrange
fake_users.seed({
"id": 1,
"email": "alice@example.com",
"last_welcome_sent": datetime(2024, 1, 1, tzinfo=UTC) if days_elapsed > 0 else None,
})
fake_clock.set_time(datetime(2024, 1, 1 + days_elapsed, tzinfo=UTC))
# Act
result = await notification_service.send_welcome_email(1)
# Assert
assert result == should_send
Pattern 6: Error Injection Pattern¶
Test error handling with configurable fakes.
# Fake with error injection
class FakePaymentGateway:
def __init__(self):
self.should_fail = False
async def charge(self, amount: float) -> dict:
if self.should_fail:
raise PaymentError("Card declined")
return {"status": "succeeded"}
# Test error handling
async def test_handles_payment_failure(fake_gateway, service):
fake_gateway.should_fail = True
with pytest.raises(PaymentError):
await service.checkout(amount=100.0)
Guidelines for Writing Fakes¶
DO:
Keep fakes simple (less logic than real implementation)
Make fakes fast (in-memory, no I/O)
Make fakes deterministic (no random behavior, controllable time)
Add test-only helpers (
seed(),verify_*(),clear())Implement the full protocol (all methods)
Put fakes in production code (reusable)
DON’T:
Make fakes complex (defeats the purpose)
Add business logic to fakes (keep them dumb)
Make fakes stateful across tests (use
clear()or fresh container)Use fakes for code that doesn’t need them (pure functions don’t need fakes)
See also
Testing Philosophy: Fakes Over Mocks - Why fakes over mocks
Test Fixtures - Container fixtures for pytest
Troubleshooting - Common pitfalls and solutions