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.
| Double | Is | Use when |
|---|---|---|
| Stub | a canned answer | the code needs a value back, and you do not care how it was asked for |
| Spy | a real or empty function that records its calls | you need to know that something was called, and with what |
| Mock | a double with expectations built in — it fails the test itself | rarely; it puts the assertion far from the eye |
| Fake | a working implementation, simplified | a 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.
// 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.
// 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.
// 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
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.