Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

When to Mock

Mock at system boundaries only:

  • External APIs (payment, email, third-party services)
  • Databases (prefer a real test DB; use an in-memory adapter when that’s impractical)
  • Time / randomness
  • File system (sometimes)

Don’t mock:

  • Your own modules or classes
  • Internal collaborators
  • Anything you control

Test Double Types

Use the naming conventions from tests/unit/test_doubles.py:

  • Stub — returns a hardcoded response, does not record calls. Use when a collaborator must satisfy a type contract but its behaviour is irrelevant to the test.
  • Spy — records how it was called and returns a preprogrammed response. Use when the test needs to assert that a collaborator was called with specific arguments.
  • Mock — returns a response based on conditional logic (a lightweight rule engine). Use sparingly; prefer Stub or Spy.
  • Dummy — satisfies a type constraint but is never invoked. Use when a dependency is required by a constructor but irrelevant to the test.

Shared doubles live in tests/unit/test_doubles.py. Test-specific inline doubles are acceptable when they are too narrow to share.

Designing for Testability with Ports and Adapters

The cleanest way to make code testable at system boundaries is to define a Port (an ABC) and inject it. Tests pass a double that implements the same port; production code passes the real adapter.

from abc import ABC, abstractmethod

# The port — defines what the module needs
class PaymentGatewayPort(ABC):
    @abstractmethod
    def charge(self, amount: int) -> Receipt: ...

# Production adapter — talks to the real payment provider
class StripeAdapter(PaymentGatewayPort):
    def charge(self, amount: int) -> Receipt:
        # real Stripe API call
        ...

# Test stub — satisfies the port, returns hardcoded data
class PaymentGatewayStub(PaymentGatewayPort):
    def charge(self, amount: int) -> Receipt:
        return Receipt(status="confirmed", amount=amount)

# Test spy — records the call so we can assert on it
class PaymentGatewaySpy(PaymentGatewayPort):
    last_charge: int | None = None

    def charge(self, amount: int) -> Receipt:
        self.last_charge = amount
        return Receipt(status="confirmed", amount=amount)

The service under test accepts the port, not the concrete adapter:

class OrderService:
    def __init__(self, payment_gateway: PaymentGatewayPort) -> None:
        self._payment_gateway = payment_gateway

    def checkout(self, cart: Cart) -> Receipt:
        return self._payment_gateway.charge(cart.total)

In tests:

# Unit test: verify behavior, use a stub
def test_checkout_returns_confirmed_receipt():
    service = OrderService(payment_gateway=PaymentGatewayStub())
    result = service.checkout(cart_with_total(100))
    assert result.status == "confirmed"

# Unit test: verify interaction, use a spy
def test_checkout_charges_correct_amount():
    spy = PaymentGatewaySpy()
    service = OrderService(payment_gateway=spy)
    service.checkout(cart_with_total(250))
    assert spy.last_charge == 250

In-Memory Adapters for Integration Tests

For integration tests, prefer a real in-memory adapter over a stub. It exercises real logic (creating, storing, retrieving) without infrastructure:

class InMemoryJokesRepository(JokesRepositoryPort):
    def __init__(self) -> None:
        self._jokes: list[Joke] = []

    def get_jokes(self) -> list[Joke]:
        return self._jokes

    def create_joke(self, joke: Joke) -> Joke:
        self._jokes.append(joke)
        return joke

This is not a mock — it has real behaviour. Use it in integration tests to wire modules together without touching a real database.

SDK-Style Interfaces Over Generic Fetchers

Create specific methods for each external operation instead of one generic method:

# GOOD: each method is independently testable and stubbable
class CredentialAgentPort(ABC):
    @abstractmethod
    def create_offer(self, credential: Credential) -> Offer: ...

    @abstractmethod
    def get_offer_status(self, offer_id: str) -> OfferStatus: ...

# BAD: mocking requires conditional logic inside the double
class CredentialAgentPort(ABC):
    @abstractmethod
    def call(self, endpoint: str, payload: dict[str, object]) -> dict[str, object]: ...

Specific methods mean each stub or spy returns one well-typed shape, with no conditional logic in test setup.