Test doubles: mocks, stubs, fakes, and spies
"Mock" gets used as a catch-all term in casual conversation, but the four kinds of test double actually do different jobs — knowing which one a situation calls for is what keeps a test both fast and honest.
4 min read
The problem all four are solving: real dependencies make tests slow, flaky, or impossible
async function sendWelcomeEmail(user, emailService) {
await emailService.send(user.email, "Welcome!");
return { sent: true };
}Testing sendWelcomeEmail by calling a real emailService that actually sends real email would make the test slow (real network I/O), flaky (fails if the email provider has a bad moment), and actively harmful (it really emails someone every time the test suite runs). A test double is a stand-in object substituted for the real dependency during a test — the umbrella term for the four specific kinds below, each suited to a different job.
Stub: returns canned answers, nothing more
const stubEmailService = {
send: () => Promise.resolve({ id: "fake-id" }), // always returns this, no matter what's passed in
};
test("returns sent: true after sending", async () => {
const result = await sendWelcomeEmail({ email: "a@example.com" }, stubEmailService);
expect(result).toEqual({ sent: true });
});A stub exists purely to make the code under test runnable — it returns a fixed, canned value so execution can continue past the point where the real dependency would have been called, and the test doesn't care whether or how the stub was actually used.
Spy: like a stub, but also records how it was called
const spyEmailService = {
send: jest.fn(() => Promise.resolve({ id: "fake-id" })),
};
test("sends to the user's actual email address", async () => {
await sendWelcomeEmail({ email: "a@example.com" }, spyEmailService);
expect(spyEmailService.send).toHaveBeenCalledWith("a@example.com", "Welcome!");
});A spy does everything a stub does, plus it remembers — how many times it was called, with what arguments, in what order. This matters when the thing worth testing isn't the return value at all but whether a side effect happened correctly: this test doesn't care what sendWelcomeEmail returns, it cares that send was actually called with the right email address.
Fake: a real, working, simplified implementation
class InMemoryUserRepository {
constructor() { this.users = new Map(); }
save(user) { this.users.set(user.id, user); }
findById(id) { return this.users.get(id) ?? null; }
}A fake isn't canned answers — it's an actual, working implementation, just a simpler one than production uses. InMemoryUserRepository really stores and retrieves data, with real logic, but keeps it in a JavaScript Map instead of a real database — no network, no disk, resets instantly between tests. Fakes are worth the extra effort to build (real logic, not just canned returns) specifically when many tests need a dependency's genuine behavior — save-then-find actually working correctly — not just a stand-in that unblocks execution.
Mock (in the strict sense): pre-programmed expectations that fail the test on their own
test("calls send exactly once, with the welcome template", () => {
const mockSend = jest.fn();
const emailService = { send: mockSend };
sendWelcomeEmail({ email: "a@example.com" }, emailService);
expect(mockSend).toHaveBeenCalledTimes(1);
});In casual usage, "mock" often just means "any fake object I gave to a test" — including what this lesson calls stubs, spies, or fakes. In the strict sense this lesson is using, a mock is specifically about verifying an interaction — that a call happened, with the right arguments, the right number of times — rather than about the state or return value the test ends up checking. jest.fn() here is functioning as a mock: the assertion is entirely about how it was called, not about any value it returned.
Choosing the right one isn't about rules — it's about what the test is actually trying to prove
The four aren't ranked by quality — each is the right tool for a specific question a test might be asking: "does this code produce the right result given some input" usually wants a stub; "did this code call the dependency correctly" wants a spy or mock; "does this code correctly use a stateful dependency across multiple calls" wants a fake. Reaching for a mock/spy by default even when the test is really about a return value — asserted next — is the exact mistake the following lesson covers.
Further reading
Check your understanding
A quick comprehension check — not tracked, not graded, just for you.
1. What's the actual difference between a stub and a spy?
2. What makes a fake different from a stub?
3. In the strict sense this lesson uses, what does a mock verify that a stub doesn't?