Test Doubles
Date: 2026-08-17
Stand-ins for real dependencies during a test. There are five distinct kinds, everyone calls all of them “mocks”, and the confusion matters because only one of them asserts on interactions — which is the one that makes tests brittle.
A test double replaces a real dependency in a test. “Mock” is the colloquial term for all of them and the precise term for one.
The five
| Kind | What it does |
|---|---|
| Dummy | Passed to satisfy a signature, never used |
| Stub | Returns canned answers. No assertions |
| Spy | A real thing, wrapped to record calls |
| Mock | A stub that asserts on how it was called |
| Fake | A working lightweight implementation |
// STUB — provides a value
vi.mocked(getPrice).mockReturnValue(4999)
// SPY — records, real behaviour intact
const spy = vi.spyOn(analytics, 'track')
// MOCK — asserts on the interaction
expect(sendEmail).toHaveBeenCalledWith(
'a@example.com', 'Order confirmed')
// FAKE — a real implementation, simplified
const repo = new InMemoryOrderRepository()The distinction that matters
Stubs answer questions. Mocks make demands.
// STUB — asserts on the OUTCOME
getPrice.mockReturnValue(4999)
expect(calculateTotal(basket)).toBe(5999)
← survives refactoring
// MOCK — asserts on the MECHANISM
expect(getPrice).toHaveBeenCalledTimes(1)
expect(getPrice).toHaveBeenCalledWith('SKU-1')
← breaks if you cache, batch, or
reorder the callsMock assertions couple the test to the implementation. Change how the code achieves the result and the test fails despite the behaviour being identical — which teaches people that refactoring is expensive — Unit Testing.
Prefer stubs. Reach for mocks only when the interaction is the behaviour — that an email was sent, that a payment was captured, that an event was recorded. In those cases the call is the outcome.
Fakes are underused
class InMemoryOrderRepository {
#orders = new Map()
async save(o) { this.#orders.set(o.id, o) }
async find(id) { return this.#orders.get(id) ?? null }
}A fake behaves correctly, so tests read naturally and don’t need re-stubbing per case. Worth building for anything used across many tests — a repository, a clock, a feature flag provider.
The risk is divergence. A fake that drifts from the real implementation gives confidence in behaviour that no longer exists — so run the same contract tests against both where you can.
Where to draw the line
DOUBLE THESE
third-party HTTP APIs
payment providers
email and SMS senders
time and randomness
anything slow, paid, or with
side effects on the world
DON'T DOUBLE THESE
your own modules
← doubling them means the unit
boundary is wrong
your database
← use a real one — Integration Testing
the framework
See: Integration Testing
Mocking your own code is the strongest smell in this note. If a function needs four of your own modules doubled, the test is documenting entanglement rather than verifying behaviour — Coupling and Cohesion.
Time and randomness
The two doubles nearly every codebase needs and few have:
vi.setSystemTime(new Date('2026-08-17T10:00:00Z'))Untested time handling is where timezone and boundary bugs live — an order placed at 23:58 on the last day of the month, a subscription renewing across a daylight-saving change. Freezing the clock makes those testable at all.
Inject them rather than reaching for globals, so they’re substitutable without a mocking framework:
function createOrder(items, { now = () => new Date() } = {}) { }Contract drift
The failure that makes heavy doubling dangerous:
your stub returns { total: 4999 }
the API now returns { totalPence: 4999 }
every test passes
production is broken
Doubles freeze your beliefs about a dependency at the moment you wrote them. Guard against it with contract tests against the real thing, running less often — nightly rather than per commit — or by intercepting at the network layer with recorded real responses.
This is the strongest argument for weighting towards integration tests, which use the real collaborators and can’t drift — Testing Strategy.