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, moderncreate_autospec). Examples target 3.11. unittest.mockfrom the standard library;pytestfor 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.
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")
store= parameter. A fake keeps coherent state and lets you assert the round-trip; a mock records calls and lets you assert the interaction. Match the double to what the behaviour guarantees.The two doubles answer different questions, and the diagram below shows which assertion each one makes possible.
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.
The decision collapses to a single question: does the behaviour under test guarantee resulting state, or guarantee that a collaborator was called? A rename that must be readable afterward guarantees state — inject a fake and assert on it. A notify that fires an email or emits an event guarantees an outward call and holds no state worth reading back — inject a mock and assert assert_called_once_with. Injecting the wrong double does not merely add noise; it changes what the test can prove. A mock in a stateful round-trip proves nothing about the round-trip, and a fake in a fire-and-forget path forces you to build state the behaviour never actually keeps.
Edge cases and failure modes
- A
Mockmakes a stateful read-after-write test lie.mock_store.load()returns a child mock unrelated to any earliersave(), 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 ancreate_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
AsyncMockand the fake's methods must beasync 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
Mockwhosenow()returns a static value; route it through the constructor and compare with freezing time: freezegun vs monkeypatch.
Keeping an injected fake honest
A fake is production code that only runs in tests, which means it can rot in exactly one direction: the real implementation changes, the fake does not, and every test that depends on it keeps passing against behaviour that no longer exists. Two cheap disciplines stop that.
The first is a shared contract test. Write one test class that exercises the interface, then run it against both implementations with a parametrized fixture. Any behaviour the fake gets wrong — a missing uniqueness constraint, a different error type, a return value that should be a copy — fails against the real implementation and vice versa.
import pytest
from myapp.storage import PostgresOrderStore
from tests.fakes import InMemoryOrderStore
@pytest.fixture(params=["fake", "real"])
def store(request, pg_dsn):
if request.param == "fake":
return InMemoryOrderStore()
return PostgresOrderStore(pg_dsn) # only this one touches the network
def test_duplicate_order_id_rejected(store):
store.put(order_id="A-1", total=10)
with pytest.raises(store.DuplicateOrder): # the contract, not the class
store.put(order_id="A-1", total=99)
def test_get_returns_a_copy(store):
store.put(order_id="A-2", total=10)
fetched = store.get("A-2")
fetched["total"] = 0 # mutating the result must not persist
assert store.get("A-2")["total"] == 10
Mark the real-backed parameter with an integration marker so the fast suite runs the fake path only and CI runs both. The point is not to run the slow test often; it is that the fake's behaviour is pinned by something executable rather than by memory.
The second discipline is to build the fake on the same abstract base or Protocol the production class implements, and to type the constructor parameter with it. A Protocol gives you a static check that the fake still satisfies every method the collaborator declares, so a new method on the interface fails type-checking in CI before any test runs. With a runtime_checkable protocol you can also assert it directly in a guard test.
Neither discipline applies to mocks, and that asymmetry is the real argument for fakes at boundaries you cross often: a mock's fidelity is asserted nowhere, so it can only be verified by a human reading the test. Reserve mocks for collaborators where the interaction is the specification — an audit log that must be written exactly once, a payment gateway that must never be called twice — and let fakes carry the collaborators whose state your assertions actually read.
The contract test above is what keeps the two implementations aligned; this is the loop it runs in.
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.
Related guides
- When constructor injection is not available, you must instead choose a patch target — see Where to Patch: Understanding mock.patch Targets.
- Bind the mock path to the real signature with autospec strict mocking so fakes and mocks share one contract.
- Choosing the mock class for the dependency is covered in Mock vs MagicMock vs AsyncMock — when to use each.
- For collaborators that are network calls, a fake transport often beats a mock — see Mocking Network and HTTP Calls.
← Back to Dependency Injection for Testability