Documentation

Testing Strategy

Ports and adapters test strategy: in-memory adapters, no mocks, and four test layers.

Show:

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:

  1. An abstract interface (port) in the application layer
  2. A real adapter for production
  3. 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 == 401

Route 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 concernDon't useUse instead
Database / repositoryMock repositoryInMemoryRepository
Event publishingMock publisherInMemoryEventPublisher
Performance monitoringMock monitorInMemoryPerformanceMonitor
Email / notificationsMock functionIn-memory collector adapter
Third-party HTTP APIsMock functionMSW (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.py is 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_overrides is 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_store dict — no shared state between tests.