Fakes¶
A fake is a test double with a real working implementation, just a simplified one that is unsuitable for production. An in-memory repository, an in-process message bus, an email sender that appends to a list.
graph LR
P["Production: SQL repository"] --> IFACE[Same interface]
F["Test: in-memory repository backed by a dictionary"] --> IFACE
IFACE --> U[Unit under test, unaware which it has]
Unlike a stub, a fake actually behaves: save then load returns what was saved, a duplicate key is rejected, a query filters correctly. That behaviour is what makes it usable across many tests without per-test configuration.
Why fakes beat piles of stubs¶
| Stubs everywhere | One shared fake | |
|---|---|---|
| Setup per test | Configure every call the unit might make | Insert the data the scenario needs |
| Unforeseen calls | Return defaults or blow up | Behave correctly |
| Readability | Setup dominates the test | The scenario is visible |
| Maintenance | Every interface change touches many tests | One place to update |
| Realism | Encodes assumptions about the real thing | Encodes actual behaviour, simplified |
class InMemoryOrders(OrderRepository):
def __init__(self):
self._rows = {}
def save(self, order):
if order.id in self._rows:
raise DuplicateOrder(order.id)
self._rows[order.id] = order
def by_customer(self, customer_id):
return [o for o in self._rows.values() if o.customer_id == customer_id]
Thirty lines written once replaces stub configuration in a hundred tests, and the duplicate rejection means tests exercise the same rule the real repository enforces.
The fidelity problem¶
A fake is only as useful as its resemblance to the real thing, and every fake drifts.
graph LR
REAL[Real implementation changes] --> GAP{Fake updated?}
GAP -->|no| DRIFT[Tests green, production broken]
GAP -->|yes| OK[Fake stays trustworthy]
Two defences:
- A shared contract test suite run against both the real implementation and the fake. The same test cases, two implementations, so any divergence fails immediately. This is the single most valuable practice around fakes.
- Integration tests against the real thing. Fakes make unit tests fast, they never remove the need to exercise the real database, broker or provider at the integration level.
Where fakes fit well¶
| Dependency | Fake |
|---|---|
| Repository or data store | In-memory collection with the same query semantics |
| Message broker | In-process queue with the same delivery guarantees |
| Email or SMS sender | Collector that records what would have been sent |
| Clock | Controllable clock that advances on command |
| File storage | In-memory or temporary directory implementation |
| Payment provider | Simplified simulator covering approve, decline and timeout |
The controllable clock is the highest value of these. It converts every time-dependent test, expiry, retries, scheduling, from slow and flaky into fast and deterministic.
When not to fake¶
- When the real thing is fast enough. A containerised database that starts in seconds is usually better than an in-memory imitation of it, because it is the actual engine.
- When the fake would need the real logic. If faithfully faking something means reimplementing it, you are writing a second production system with no tests of its own.
- When the fidelity risk is the point. Migrations, SQL dialect, constraint behaviour and transaction semantics must be tested against the real engine, never against a fake.
Check Your Understanding¶
What makes a fake different from a stub?
How is the risk of a fake drifting away from the real implementation best managed?