Test Doubles: Mocks, Stubs, Spies, Fakes
Isolate the code under test from its collaborators โ without letting the doubles themselves become lies.
A unit test must fail because your code is wrong โ not because the network was down, the clock drifted, or the database was empty. Test doubles replace collaborators with controllable stand-ins.
The taxonomy (precisely)
- Stub โ returns canned answers.
stubFetchthat always resolves{"ok": true}. You don't assert on it; your code calls it and you assert on the result. - Spy โ wraps a real (or fake) function and records calls: arguments, count, order. You assert on how your code used it.
- Mock โ a stub with expectations pre-loaded: "this must be called once with X, or the test fails." Powerful; overuse couples tests to implementation.
- Fake โ a real lightweight implementation: an in-memory Map instead of a database, a queue that actually queues.
The dependency seam
You can only substitute what your code can reach from outside. This is why hardcoded dependencies are a testing smell:
// untestable as a unit โ fetch is welded in
export async function loadUser(id) {
const res = await fetch(`/api/users/${id}`);
return res.json();
}
// testable โ the collaborator arrives by parameter
export function makeUserLoader(fetchLike) {
return async (id) => {
const res = await fetchLike(`/api/users/${id}`);
return res.json();
};
}
Constructor injection, function parameters, or module-level indirection โ any seam works. The pattern is the point, not the mechanism.
The classic spy, by hand
function makeSpy(impl) {
const calls = [];
const spy = (...args) => {
calls.push(args);
return impl?.(...args);
};
spy.calls = calls;
return spy;
}
Then: spy.calls.length === 2, spy.calls[0][0] === "/api/users/42". This is literally what vi.fn() / jest.fn() are.
What NOT to double
Don't stub the function under test's own pure logic โ only collaborators with side effects (network, clock, storage, randomness). Over-mocked tests pass while production burns: they verify your assumptions, not your code. When a test needs five mocks to run, the code is asking for a redesign (fewer collaborators, more seam, smaller unit).