Skip to main content

Test Doubles: Mocks, Stubs, Spies, Fakes

intermediate20 min readLesson 101 of 143

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. stubFetch that 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).

Now practice

Seams & Doubles โ€” PracticeBuild spies by hand, stub async collaborators, and design injection seams that make code testable.3 challenges ยท ยท ~20 min