A repository is the most mocked collaborator in most codebases, and the one where mocks do the most damage. A Mock() standing in for repo.get returns whatever the test configured, never raises NotFound, and never notices that the code called save with an object missing a required field. An in-memory fake that behaves like the real repository — storing, retrieving, rejecting duplicates, ordering results — lets business-logic tests run in microseconds while still exercising the interaction with storage honestly.
Writing one takes an afternoon for a typical repository, and the investment is recovered the first week: every test that previously configured three or four mock return values to simulate storage becomes a test that adds a couple of objects to the fake and asserts on what it contains afterwards. The work is in the details that make the fake trustworthy: storing copies so mutation does not leak, reproducing the error cases the real implementation raises, matching its ordering, and proving all of that with a contract suite that runs against both.
Prerequisites
- A repository interface expressed as a
typing.Protocolor an abstract base class. pytest >= 8.0, plus the real repository available in an integration stage for the contract suite's other half.- The reasoning behind fakes over mocks, in spies, fakes and hand-rolled test doubles.
Solution
import copy
from datetime import datetime
from myapp.errors import ConcurrentUpdate, DuplicateOrder, OrderNotFound
from myapp.models import Order
class FakeOrderRepository:
"""In-memory OrderRepository with the same observable behaviour as the SQL one."""
def __init__(self) -> None:
self._rows: dict[str, Order] = {}
def get(self, order_id: str) -> Order:
try:
# A copy: callers mutating the result must not change stored state.
return copy.deepcopy(self._rows[order_id])
except KeyError:
raise OrderNotFound(order_id) from None
def add(self, order: Order) -> None:
if order.id in self._rows:
raise DuplicateOrder(order.id) # the real unique constraint
self._rows[order.id] = copy.deepcopy(order)
def update(self, order: Order) -> None:
current = self._rows.get(order.id)
if current is None:
raise OrderNotFound(order.id)
if current.version != order.version: # optimistic locking, as in SQL
raise ConcurrentUpdate(order.id)
stored = copy.deepcopy(order)
stored.version += 1
self._rows[order.id] = stored
def list_open(self, *, customer_id: str) -> list[Order]:
rows = [o for o in self._rows.values()
if o.customer_id == customer_id and o.status == "open"]
# Same ORDER BY as the SQL implementation: newest first, then id.
return [copy.deepcopy(o) for o in
sorted(rows, key=lambda o: (-o.created_at.timestamp(), o.id))]
Why this works
A repository's contract is small: a handful of methods, the exceptions they raise, and the guarantees about what comes back. A dictionary keyed by identifier reproduces the storage, and a few explicit checks reproduce the constraints the database would enforce. Because the fake implements the same Protocol, the type checker verifies that its methods and signatures match, and the code under test cannot tell which implementation it received. That indistinguishability is the whole goal: the code under test should exercise its real logic, including its error handling, with the only difference being where the data lives.
Deep copies are the detail that closes the most dangerous gap. With references, a test that saves an order, mutates it, and then loads it would see the mutation — and conclude that some code path persisted the change when in reality nothing did. The real repository returns fresh objects every time; the fake must too.
Edge cases and failure modes
- Storing references. Mutations after save appear persisted. Deep-copy on both write and read.
- Missing error cases. A fake that never raises
DuplicateOrdermeans the duplicate-handling path is never exercised. Every exception the real implementation raises belongs in the fake. - Different ordering. Dictionary insertion order is not the SQL
ORDER BY. Tests that pass against the fake's order fail against the real one. Sort explicitly, matching the query. - Fake-only helpers used by production code. A
clear()orall()convenience added for tests must never be called by the code under test. Keep test helpers visibly separate. - Overbuilding. A fake that grows query filters, pagination cursors and full-text search is reimplementing the database. Move those tests to the integration layer.
The contract suite that keeps it honest
The fake is only as good as its agreement with the real repository, and the only reliable way to maintain that agreement is to run the same behavioural tests against both.
import pytest
class OrderRepositoryContract:
"""Every implementation must pass these."""
def test_get_missing_raises(self, repo):
with pytest.raises(OrderNotFound):
repo.get("missing")
def test_duplicate_add_raises(self, repo, an_order):
repo.add(an_order)
with pytest.raises(DuplicateOrder):
repo.add(an_order)
def test_stale_update_raises(self, repo, an_order):
repo.add(an_order)
first, second = repo.get(an_order.id), repo.get(an_order.id)
repo.update(first)
with pytest.raises(ConcurrentUpdate):
repo.update(second) # stale version
def test_mutation_after_add_is_not_persisted(self, repo, an_order):
repo.add(an_order)
an_order.status = "cancelled"
assert repo.get(an_order.id).status == "open"
class TestFakeOrderRepository(OrderRepositoryContract):
@pytest.fixture
def repo(self):
return FakeOrderRepository()
@pytest.mark.integration
class TestSqlOrderRepository(OrderRepositoryContract):
@pytest.fixture
def repo(self, db_session):
return SqlOrderRepository(db_session)
The last test is worth singling out. It passes trivially against the real repository and fails against a fake that stores references, which makes it the single most valuable assertion in the suite: it catches the mistake that makes fake-backed tests lie.
Using the fake in business-logic tests
With a trustworthy fake, the tests for everything above the repository change character. They stop configuring return values and start describing situations, and the assertions move from "which methods were called" to "what state resulted".
def test_cancelling_an_order_releases_its_stock(order_service, repo, stock):
repo.add(an_order(id="ord_1", status="open", lines=[a_line(sku="SKU-1", quantity=2)]))
stock.reserve("SKU-1", 2)
order_service.cancel("ord_1")
assert repo.get("ord_1").status == "cancelled"
assert stock.reserved("SKU-1") == 0
The arrangement reads as a description of the world before the operation; the assertions read as a description of the world after it. Nothing in the test depends on how cancel talks to the repository — whether it loads then updates, uses a dedicated method, or batches — so the implementation can change freely as long as the outcome stays the same. That is the practical payoff of fakes over mocks, and it compounds: a service with fifty tests written this way can be restructured without touching any of them.
It also makes a class of bug visible that mock-based tests hide completely. If cancel forgot to save the updated order, a mock-based test that asserted repo.update.assert_called_once() would fail only if the author thought to check it; the fake-based test fails because repo.get("ord_1").status is still "open". The state assertion catches omissions without anyone having to anticipate them. That property alone justifies the fake for any repository used by more than a handful of tests.
Where the fake should live
A fake used by one module's tests can live beside them. A fake used across the codebase — and a repository fake almost always is — belongs next to the real implementation, in the application package, exported alongside it. That placement has three effects worth wanting. The fake is found by anyone looking at the repository code. It is reviewed in the same pull requests that change the real implementation, so drift is visible in the diff. And other packages that depend on this one can import the fake for their own tests rather than writing inferior copies.
The contract suite lives with it for the same reason. When someone adds a method to the real repository, the natural place to add its contract test is right there, and the fake half of that test fails immediately until the fake gains the same method — which is exactly the moment the author has all the context needed to implement it correctly. Deferring that work to whoever next needs the fake method means implementing it without that context, which is how fakes acquire subtly wrong behaviour in the first place.
Frequently Asked Questions
Should the fake store objects or copies of them? Copies. The real repository returns fresh objects from the database, so mutating one after saving does not change what is stored. A fake that stores references lets tests pass because a later mutation "saved" itself, which the real repository would never do.
How much of the real repository's behaviour should the fake reproduce? Everything the code under test depends on, including the error cases — not found, duplicate key, optimistic-lock conflict — and ordering guarantees. It does not need transactions, connection handling or query planning. The contract suite defines exactly where the line is.
What stops the fake drifting from the real implementation? A shared contract test suite that runs against both. Any behaviour the code relies on is asserted once, and both implementations must pass. The real half runs in the integration stage; the fake half runs everywhere.
Related
- Spies, Fakes & Hand-Rolled Test Doubles — when a fake beats a mock.
- Injecting Fakes vs Mocks in Constructors — getting the fake into the code under test.
- Database Fixtures & Transactional Tests — running the SQL half of the contract suite.
- Parametrizing Fixtures with params and ids — the alternative way to run one suite over both.
← Back to Spies, Fakes & Hand-Rolled Test Doubles