Isolation & Contracts

Injecting Fakes vs Mocks in Constructors

A Mock is the reflexive choice for any collaborator, but it quietly fails the moment a test depends on the dependency remembering something: a repository double that should return what was just written returns a fresh child mock instead, and the test either passes vacuously or forces you to script every call with brittle side_effect lists. The fix is a decision, made at the constructor seam, between a hand-written fake — a real in-memory implementation of the interface — and a unittest.mock double. This guide frames that trade-off: constructor injection as the seam, when a fake's coherent state beats a Mock, and how the choice pins you to either state testing or interaction testing.

Prerequisites

  • Python 3.8+ (unittest.mock.AsyncMock, modern create_autospec). Examples target 3.11.
  • unittest.mock from the standard library; pytest for the test bodies.
  • Comfort with constructor injection as a design pattern. The Dependency Injection for Testability overview covers the seam in depth.

Solution

Expose the collaborator as a constructor parameter. Then the same class under test accepts a fake when state matters and a mock when only interactions matter.

Python
from typing import Protocol
from unittest.mock import create_autospec


# The contract both the real impl, the fake, and the mock satisfy.
class UserStore(Protocol):
    def save(self, user_id: int, name: str) -> None: ...
    def load(self, user_id: int) -> str | None: ...


class UserService:
    # Constructor injection: the dependency is explicit and swappable.
    # A real default keeps production wiring unchanged.
    def __init__(self, store: UserStore) -> None:
        self._store = store

    def rename(self, user_id: int, new_name: str) -> str:
        current = self._store.load(user_id)        # read existing state
        if current is None:
            raise KeyError(user_id)
        self._store.save(user_id, new_name)        # write new state
        return new_name


# --- FAKE: a real in-memory implementation. Holds coherent state across calls.
class FakeUserStore:
    def __init__(self) -> None:
        self._data: dict[int, str] = {}

    def save(self, user_id: int, name: str) -> None:
        self._data[user_id] = name                 # actually stores

    def load(self, user_id: int) -> str | None:
        return self._data.get(user_id)             # returns what was stored


def test_rename_with_fake_state_testing():
    fake = FakeUserStore()
    fake.save(1, "old")                            # arrange real state
    service = UserService(store=fake)              # inject the fake

    service.rename(1, "new")

    # STATE testing: assert on the fake's observable state afterward.
    assert fake.load(1) == "new"


def test_rename_with_mock_interaction_testing():
    # autospec binds the double to the UserStore signature (catches drift).
    mock_store = create_autospec(UserStore, instance=True)
    mock_store.load.return_value = "old"           # script the read
    service = UserService(store=mock_store)        # inject the mock

    service.rename(1, "new")

    # INTERACTION testing: assert HOW the collaborator was used.
    mock_store.load.assert_called_once_with(1)
    mock_store.save.assert_called_once_with(1, "new")

Why this works

Constructor injection turns the dependency into a value the test supplies, so swapping a fake for a mock requires no patching and no knowledge of where the dependency is imported. A fake is a genuine implementation: its load returns whatever its save stored, so a read-modify-write flow behaves coherently and the assertion checks resulting state. A mock returns scripted values per call and records invocations, so it naturally supports interaction testing — but it has no memory linking a save to a later load, which is exactly why stateful flows want a fake.

Edge cases and failure modes

  • A Mock makes a stateful read-after-write test lie. mock_store.load() returns a child mock unrelated to any earlier save(), so a test asserting "the saved value is readable" passes without exercising the contract. Use a fake when the behavior under test is the round-trip itself.
  • Over-specified interaction tests turn into change detectors. Asserting every call on a mock couples the test to the implementation; a harmless refactor (an extra cache read) breaks it. Assert only the interactions that are part of the guarantee, or switch to state testing with a fake.
  • Fakes drift from the real interface silently. A hand-written fake can fall out of sync when the real class adds a method. Define the interface as a Protocol (or build the fake from the same base) and consider an create_autospec(Interface, instance=True) smoke test so autospec strict mocking catches signature drift in the mock path.
  • Async collaborators need awaitable doubles. If the dependency is a coroutine interface, the mock must be an AsyncMock and the fake's methods must be async def; see Mock vs MagicMock vs AsyncMock — when to use each for picking the class.
  • Time and randomness are stateful dependencies too. A fake clock that advances deterministically usually beats a Mock whose now() returns a static value; route it through the constructor and compare with freezing time: freezegun vs monkeypatch.

Frequently Asked Questions

When does a hand-written fake beat a Mock? Use a fake when the dependency holds state across calls — a store that must remember writes, a clock that advances — and your assertions check resulting state rather than which methods ran. A Mock returns canned values per call and does not maintain coherent internal state.

Should I inject the dependency in the constructor or patch it? Inject through the constructor when you own the class. The constructor seam makes the dependency explicit, lets the test pass either a fake or a mock, and avoids brittle patch targets. Reserve patching for third-party code you cannot route through init.

How do fakes and mocks differ for verification? Mocks support interaction testing: assert_called_with and call_count verify how the collaborator was used. Fakes support state testing: you assert on the fake's observable state after the action. Pick the style that matches what the behavior actually guarantees.

← Back to Dependency Injection for Testability