Test doubles

Replace the parts you cannot call in a test — with the smallest thing that does the job.

06of 16
≈ 13Min read
5Parts

Four words people use interchangeably, and should not

The fake is the underrated one. A repository backed by a Map behaves like the real thing for every test you write, is faster than a mock to set up, and cannot drift into asserting how your code calls its dependency.

DoubleIsUse when
Stuba canned answerthe code needs a value back, and you do not care how it was asked for
Spya real or empty function that records its callsyou need to know that something was called, and with what
Mocka double with expectations built in — it fails the test itselfrarely; it puts the assertion far from the eye
Fakea working implementation, simplifieda repository backed by a Map, an in-memory queue, SQLite instead of Postgres

Stub what you do not control

That is the whole list. Substituting something because it is merely awkward is how a suite ends up testing its own mocks — and it is why the trophy shape in the first lesson prefers a real database over a mocked one.

the three real reasons to substitute anything
// 1. it is slow            — the network, the disk, a real payment provider
// 2. it is unpredictable   — the clock, randomness, a live API
// 3. it has side effects   — sending an email, charging a card, deleting a file

Inject the dependency

A default parameter keeps the production call sites unchanged and makes the test a one-liner. Module-level mocking (vi.mock, jest.mock) exists for the code you cannot change — it is powerful, it is global, and it is the thing that makes a suite mysterious when two files disagree about what is mocked.

the only design choice that makes doubles pleasant
// hard to test: the dependency is reached for, not given
import { sendMail } from "./mail.js";

export async function confirm(order) {
  await sendMail(order.email, "Your quote");
}

// easy: the caller decides what "send" means
export async function confirm(order, { send = sendMail } = {}) {
  await send(order.email, "Your quote");
}

// in the test
const send = spy();
await confirm(order, { send });

expect(send.calledWith("a@b.test", "Your quote")).toBe(true);

Assert on the outcome, not on the choreography

A spy is for facts you cannot observe any other way — "an email was sent to this address" — not for narrating the function line by line. If a test reads like the implementation written twice, it is a mock test, and it will break for no reason.

the same test, two qualities
// choreography: fails when you add a log line or reorder two calls
expect(repo.save.callCount).toBe(1);
expect(repo.save.calls[0][0].status).toBe("paid");
expect(mail.send.callCount).toBe(1);

// outcome: fails when the behaviour is wrong
expect(await repo.find(order.id)).toEqual({ ...order, status: "paid" });
expect(mail.sent).toHaveLength(1);

The double that lies

Your stub and reality can drift apart

A stub returning { ok: true } keeps passing long after the real API started returning { status: "ok" }. That is the standing risk of every double, and the reason for a small number of integration tests against the real thing — plus contract tests, where both sides agree on a shared example.

Practice