Testing Strategy
Ports and adapters test strategy: in-memory adapters, no mocks, and four test layers.
Related: 11-testing-practices/02-test-types.md
Framework implementations: FastAPI 18-framework-implementations/fastapi/12-testing.md · Next.js 18-framework-implementations/nextjs/08-testing.md
The Core Principle
In a ports-and-adapters architecture, every infrastructure concern (database, event bus, monitoring, storage, email) has:
- An abstract interface (port) in the application layer
- A real adapter for production
- An in-memory adapter for tests
The in-memory adapter is not a mock. It is a real implementation of the interface that stores state in memory. It satisfies the same contract as the production adapter — the same interface, the same invariants, real behaviour.
This means:
- Tests exercise real application and domain logic against a real (in-memory) implementation
- No mock setup, verification overhead, or mock drift
- Test failures indicate genuine bugs — not misconfigured stubs
- Swapping the production adapter (e.g. SQLAlchemy → DynamoDB) does not break tests
Three Test Layers
Layer 1 — Domain / Unit Tests
Scope: Domain entities and pure domain logic only. Dependencies: None. No adapters, no framework, no I/O. Speed: Sub-millisecond per test.
Domain tests verify that business rules hold:
- Entities enforce their invariants
- State transitions produce the correct output
- Domain errors are raised for invalid operations
def test_account_cannot_be_archived_twice():
account = Account.create(phone="+1234567890")
account.archive()
with pytest.raises(AccountAlreadyArchivedError):
account.archive()If a domain test requires any adapter or infrastructure, the logic is probably in the wrong layer.
Layer 2 — Application Handler / Integration Tests
Scope: Application handlers (use cases / command handlers) wired with in-memory adapters. Dependencies: In-memory adapters for all ports (repository, unit of work, event publisher, monitor). Speed: Milliseconds — no network, no database, no framework startup.
This is the primary confidence layer. Handler tests verify:
- Commands and queries produce the correct domain outcomes
- Repository operations persist and retrieve correctly (via in-memory store)
- Events are published with the correct type and payload
- Authorization rules are enforced
async def test_create_account_publishes_event(
account_handler, # wired with InMemoryUnitOfWork
event_publisher, # InMemoryEventPublisher instance
):
await account_handler.create(
CreateAccountCommand(phone="+1234567890"),
actor=admin_actor(),
)
assert len(event_publisher.events) == 1
assert event_publisher.events[0].event_type == "account.created"No database, no network. The in-memory adapters hold state between calls within a test; each test starts from a clean state.
Layer 3 — Route / API Tests
Scope: Full HTTP stack — middleware, routing, serialisation, application handlers — with in-memory adapters. Dependencies: Test HTTP client, in-memory adapters injected via the DI system. Speed: Tens of milliseconds per test.
Route tests verify:
- Request deserialization and validation (correct 4xx for bad input)
- Auth middleware enforcement (unauthenticated → 401, insufficient roles → 403)
- Correct HTTP status codes and response shapes
- Error handling middleware maps domain errors to correct HTTP codes
async def test_unauthenticated_request_returns_401(test_client):
response = await test_client.get("/api/v1/accounts")
assert response.status_code == 401Route tests use the real application with in-memory adapters substituted via the framework's dependency injection mechanism. They do not test business logic — that belongs in Layer 2.
Layer 4 — E2E / Browser Tests
Scope: Complete user journeys through the deployed UI or API in a staging environment. Dependencies: Real environment, real external services (or staging equivalents). Speed: Seconds to minutes.
E2E tests verify end-to-end journeys that cannot be covered at the route-test layer:
- Complete user flows (sign up → first action → key workflow)
- Cross-service interactions
- UI rendering and interaction (browser-level)
Keep the E2E suite small and focused — cover critical paths only. Speed and reliability decrease as the suite grows.
The "No Mocks" Rule in Practice
| Infrastructure concern | Don't use | Use instead |
|---|---|---|
| Database / repository | Mock repository | InMemoryRepository |
| Event publishing | Mock publisher | InMemoryEventPublisher |
| Performance monitoring | Mock monitor | InMemoryPerformanceMonitor |
| Email / notifications | Mock function | In-memory collector adapter |
| Third-party HTTP APIs | Mock function | MSW (JS) / respx (Python async) |
The exception is third-party HTTP APIs that have no in-memory implementation. Use a request-intercepting library (MSW for JavaScript, respx for Python async). These intercept at the transport layer and verify real request/response shapes — they are closer to a real integration than a function mock.
In-Memory Adapters Are Production-Quality Code
Every infrastructure adapter directory has an inmemory/ counterpart:
infrastructure/
├── database/
│ ├── adapters/
│ │ ├── sqlalchemy/ ← production
│ │ └── inmemory/ ← tests (maintained alongside production)
├── events/
│ ├── adapters/
│ │ ├── sns/ ← production
│ │ └── inmemory/ ← tests
When the interface (port) changes, both adapters are updated. The in-memory adapter is not throwaway scaffolding — it is maintained, reviewed, and versioned alongside the production adapter.
Fixtures Provide the In-Memory Wiring
The test conftest wires in-memory adapters in place of production ones. This is the only place where the choice of "which adapter" is made for tests:
@pytest.fixture
def event_publisher():
return InMemoryEventPublisher()
@pytest.fixture
def unit_of_work(event_publisher):
return InMemoryUnitOfWork(event_publisher=event_publisher)
@pytest.fixture
def account_handler(unit_of_work, event_publisher):
return AccountCommandHandler(uow=unit_of_work, event_publisher=event_publisher)Individual tests receive pre-wired handlers — they don't know or care what adapter is underneath.
Rules
- In-memory adapters are the primary test double — mocks are a last resort, not a default.
- Domain tests have zero infrastructure dependencies.
- Handler tests (Layer 2) use in-memory adapters — never a real database or network.
- Route tests (Layer 3) test the HTTP contract — not business logic.
- E2E tests (Layer 4) cover journeys — keep the suite small.
- In-memory adapters are maintained as production-quality code, not throwaway scaffolding.
- Test file structure mirrors production file structure.
conftest.pyis the only place in-memory adapters are selected — not in individual test files.
Framework implementations
Abstract reference: 11-testing-practices/
The in-memory adapter pattern means tests never need a real database, Redis, SNS, or CloudWatch. All infrastructure is swapped for in-memory equivalents via conftest.py fixtures and FastAPI's dependency override mechanism.
Test Structure
tests/
├── conftest.py # Shared fixtures: in-memory adapters, test client
├── test_routes/ # Full HTTP integration tests (route → handler → repo)
│ ├── test_accounts.py
│ ├── test_auth.py
│ └── test_health.py
├── test_handlers/ # Application handler unit tests (no HTTP)
│ ├── test_create_account.py
│ └── test_update_account.py
└── test_repositories/ # Repository tests (in-memory adapter)
└── test_account_repository.py
Core Fixtures (tests/conftest.py)
import pytest
from httpx import AsyncClient, ASGITransport
from api.main import create_app
from api import dependencies
from infrastructure.database.adapters.inmemory.repositories.account_repository import InMemoryAccountRepository
from infrastructure.database.adapters.inmemory.unit_of_work import InMemoryUnitOfWork
from infrastructure.events.adapters.inmemory import InMemoryEventPublisher
from infrastructure.monitoring.adapters.inmemory import InMemoryPerformanceMonitor
from infrastructure.authentication.jwt_token import PyJWTService
from config.settings import Settings
@pytest.fixture
def test_settings() -> Settings:
return Settings(
environment="testing",
database_adapter="inmemory",
event_adapter="inmemory",
monitoring_adapter="inmemory",
auth_jwt_secret="test-secret-key",
auth_jwt_access_token_expire_minutes=60,
)
@pytest.fixture
def account_store() -> dict:
"""Shared in-memory store — passed to both repo and UoW."""
return {}
@pytest.fixture
def account_repo(account_store):
return InMemoryAccountRepository()
@pytest.fixture
def unit_of_work(account_store):
return InMemoryUnitOfWork(account_store)
@pytest.fixture
def event_publisher():
return InMemoryEventPublisher()
@pytest.fixture
def monitor():
return InMemoryPerformanceMonitor()
@pytest.fixture
def jwt_service(test_settings):
return PyJWTService(
secret=test_settings.auth_jwt_secret,
access_expire_minutes=test_settings.auth_jwt_access_token_expire_minutes,
)
@pytest.fixture
async def client(
account_repo, unit_of_work, event_publisher, monitor, jwt_service, test_settings
):
"""AsyncClient with all dependencies overridden to use in-memory adapters."""
app = create_app(test_settings)
app.dependency_overrides[dependencies.get_account_repository] = lambda: account_repo
app.dependency_overrides[dependencies.get_unit_of_work] = lambda: unit_of_work
app.dependency_overrides[dependencies.get_event_publisher] = lambda: event_publisher
app.dependency_overrides[dependencies.get_performance_monitor] = lambda: monitor
app.dependency_overrides[dependencies.get_jwt_service] = lambda: jwt_service
async with AsyncClient(
transport=ASGITransport(app=app), base_url="http://test"
) as c:
yield c
@pytest.fixture
def auth_token(jwt_service) -> str:
"""A valid JWT for a test user."""
from uuid import uuid4
return jwt_service.generate_token(
account_id=uuid4(),
account_type="user",
roles=["user"],
)
@pytest.fixture
def auth_headers(auth_token) -> dict:
return {"Authorization": f"Bearer {auth_token}"}Route Integration Tests
# tests/test_routes/test_accounts.py
import pytest
@pytest.mark.asyncio
async def test_create_account_returns_201(client):
response = await client.post("/api/v1/accounts", json={
"email": "[email protected]",
"display_name": "Test User",
"password": "securepassword123",
})
assert response.status_code == 201
data = response.json()
assert data["email"] == "[email protected]"
assert "id" in data
@pytest.mark.asyncio
async def test_get_account_requires_auth(client):
response = await client.get("/api/v1/accounts/some-id")
assert response.status_code == 401
@pytest.mark.asyncio
async def test_get_account_not_found_returns_404(client, auth_headers):
response = await client.get(
"/api/v1/accounts/00000000-0000-0000-0000-000000000000",
headers=auth_headers,
)
assert response.status_code == 404
assert response.json()["error_code"] == "ACCOUNT_NOT_FOUND"Handler Unit Tests
# tests/test_handlers/test_create_account.py
import pytest
from application.account.create import CreateAccountHandler, CreateAccountCommand
@pytest.mark.asyncio
async def test_create_account_publishes_event(account_repo, unit_of_work, event_publisher, monitor):
handler = CreateAccountHandler(
account_repository=account_repo,
unit_of_work=unit_of_work,
event_publisher=event_publisher,
monitor=monitor,
)
command = CreateAccountCommand(email="[email protected]", display_name="Test")
account = await handler.execute(command)
assert account.email == "[email protected]"
assert len(event_publisher.published) == 1
assert event_publisher.published[0].event_type == "account.created"
@pytest.mark.asyncio
async def test_create_account_duplicate_email_raises_conflict(
account_repo, unit_of_work, event_publisher, monitor
):
handler = CreateAccountHandler(account_repo, unit_of_work, event_publisher, monitor)
command = CreateAccountCommand(email="[email protected]", display_name="Test")
await handler.execute(command)
with pytest.raises(ConflictError):
await handler.execute(command)Configuration
# pyproject.toml
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]
[tool.coverage.run]
source = ["api", "application", "domain", "infrastructure"]
omit = ["tests/*", "*/migrations/*"]Rules
- Tests never connect to a real database, Redis, SNS, or AWS service. All adapters are in-memory.
dependency_overridesis the canonical way to swap adapters in route tests — do not monkeypatch module-level globals.- Route tests exercise the full HTTP stack (middleware, auth, routing, serialisation). Handler tests exercise business logic only.
- Test the in-memory adapter's observable state (
event_publisher.published,monitor.metrics) to verify side effects — don't mock method calls. - Each test gets a fresh
account_storedict — no shared state between tests.