“Just mock it” covers a lot of different moves. Replacing a database with an in-memory map, making an HTTP client always return a 503, and checking that an email was sent exactly once are all called mocking in casual conversation, but they do different jobs and fail in different ways. Interviewers ask about the distinction because picking the wrong one is how test suites end up either brittle or blind.
The vocabulary comes from Gerard Meszaros’s xUnit Test Patterns, popularised by Martin Fowler. “Test double” is the umbrella term, by analogy with a stunt double: anything that stands in for a real dependency during a test.
Five doubles, one sentence each
- Dummy: passed to satisfy a parameter list and never used, like a logger the code path under test never touches.
- Stub: returns canned answers to calls, so the test can steer the code down a particular path. It doesn’t care how it’s called.
- Spy: a stub that also records how it was called, so the test can check afterwards.
- Mock: set up with expectations about the calls it should receive, and fails the test if they don’t happen. In practice, most JavaScript developers use spies and assert afterwards, and call the result a mock.
- Fake: a real, working implementation that takes a shortcut, such as an in-memory repository instead of PostgreSQL. It behaves; it isn’t scripted.
Library naming muddies this. mock.fn() in node:test and jest.fn() in Jest create what Meszaros would call spies: they record calls and you assert on them later. That’s fine. What matters is knowing which job you’re asking the double to do.
A password-reset service with three dependencies
Here’s a service with a typical mix of collaborators: a user store, a token store, and a mailer. It also takes a token generator and a clock, so tests can make both predictable.
const THIRTY_MINUTES = 30 * 60_000;
export class PasswordReset {
constructor({ users, tokens, mailer, newToken, now }) {
Object.assign(this, { users, tokens, mailer, newToken, now });
}
async request(email) {
const user = await this.users.findByEmail(email);
if (!user) return { ok: true }; // same answer either way: don't reveal which emails have accounts
const token = this.newToken();
await this.tokens.save({ token, userId: user.id, expiresAt: this.now() + THIRTY_MINUTES });
try {
await this.mailer.send({
to: user.email,
template: 'password-reset',
link: `https://app.example/reset?token=${token}`,
});
} catch {
await this.tokens.delete(token);
return { ok: false, retryable: true };
}
return { ok: true };
}
async complete(token, newPassword) {
const row = await this.tokens.find(token);
if (!row || row.expiresAt <= this.now()) return { ok: false, error: 'invalid_token' };
await this.users.setPassword(row.userId, newPassword);
await this.tokens.delete(token);
return { ok: true };
}
}A small setup() helper builds the default doubles: in-memory fakes for users and tokens, a spy mailer ({ send: mock.fn(async () => {}) }), a stub token generator that always returns 'tok-1', and a stub clock fixed at 1_000_000. Each test overrides only what it needs.
A fake, for a scenario with several steps
“A reset link works exactly once” spans two calls and depends on state carried between them: request stores a token, the first complete uses it, the second must find it gone. Scripting that with stubs would mean telling tokens.find to return a row the first time and undefined the second, which is just re-implementing the token store badly inside the test. A fake does it for real:
test('a reset link works exactly once', async () => {
const { reset, users } = setup();
await reset.request('ana@example.com');
assert.deepEqual(await reset.complete('tok-1', 'new passphrase'), { ok: true });
assert.equal(users.passwordOf(7), 'new passphrase');
assert.deepEqual(await reset.complete('tok-1', 'again'), { ok: false, error: 'invalid_token' });
});The test asserts on outcomes (the password changed, the second use is refused) and never on which repository methods ran. If someone later changes complete to mark tokens as used instead of deleting them, this test keeps passing, because the behavior is the same.
A stub, to force a failure you can’t easily produce
Mail servers do refuse messages, with replies like 421 4.7.0 Try again later, but you can’t make a real one do it on demand. A stub that always throws drives the code down that path:
test('when the mail server refuses, the caller can retry and no token is left usable', async () => {
const failingMailer = { send: async () => { throw new Error('421 4.7.0 Try again later'); } };
const { reset, tokens } = setup({ mailer: failingMailer });
const result = await reset.request('ana@example.com');
assert.deepEqual(result, { ok: false, retryable: true });
assert.equal(await tokens.find('tok-1'), undefined);
});The stub doesn’t check how it was called. Its only job is to make the failure happen. The assertions are about the service’s response and the state left behind: no orphaned token that would work if someone guessed it.
A mock, when the call is the behavior
Sending the email is what request is for. No return value or stored state tells you a message went out, so here verifying the interaction is the right test:
test('a known address gets exactly one reset email with the token link', async () => {
const { reset, mailer } = setup();
await reset.request('ana@example.com');
assert.equal(mailer.send.mock.callCount(), 1);
assert.deepEqual(mailer.send.mock.calls[0].arguments[0], {
to: 'ana@example.com',
template: 'password-reset',
link: 'https://app.example/reset?token=tok-1',
});
});
test('an unknown address gets the same answer and no email', async () => {
const { reset, mailer } = setup();
const result = await reset.request('nobody@example.com');
assert.deepEqual(result, { ok: true });
assert.equal(mailer.send.mock.callCount(), 0);
});The second test checks that something did not happen, which only a spy or mock can do. It also pins down a security property: the response for an unknown address is identical to a known one, so the endpoint can’t be used to discover which emails have accounts.
All four tests pass under node --test.
Fakes need tests of their own
A fake is code, and code has bugs. The danger is specific: if the fake is more forgiving than the real implementation, your tests pass against behavior production will never have. The defence is a contract test suite, one set of tests run against both the fake and the real implementation.
Here the real token store is a SQLite table with token TEXT PRIMARY KEY, using Node 22’s built-in node:sqlite. The shared contract has three tests: a saved token can be found, a deleted token is gone, and saving the same token twice is rejected. Results:
✔ InMemoryTokens: a saved token can be found (0.64475ms)
✔ InMemoryTokens: a deleted token is gone (0.496125ms)
✖ InMemoryTokens: saving the same token twice is rejected (0.612625ms)
✔ SqliteTokens: a saved token can be found (5.298834ms)
✔ SqliteTokens: a deleted token is gone (0.165083ms)
✔ SqliteTokens: saving the same token twice is rejected (0.146167ms)
AssertionError [ERR_ASSERTION]: Missing expected rejection.SQLite throws UNIQUE constraint failed: reset_tokens.token. The fake, built on Map.set, silently overwrote the first row. Any service test relying on the fake would never see a duplicate-token error, so the code that handles one (retrying with a fresh token, say) could be missing or broken and still pass. The fix is one guard clause in the fake’s save that throws if the key already exists. Then the contract suite is green for both.
Keep the contract suite small and focused on behavior callers depend on: uniqueness, “not found” returning undefined rather than null, ordering, case sensitivity. Run it against the real implementation in CI, even if the rest of the suite uses the fake.
Choosing between them
A workable rule of thumb:
- Use the real thing when it’s fast, deterministic and in-process: pure functions, value objects, the domain classes your unit is built from. Doubling those is how tests become coupled to structure.
- Use a fake for stateful dependencies across several steps (repositories, caches, queues), and give it a contract suite.
- Use a stub for queries, calls that return data, especially to force errors, timeouts or edge-case responses.
- Use a mock or spy for commands that leave your process and are the point of the code: sending email, charging a card, publishing an event. Assert on what the receiver cares about, not every argument.
The over-mocking smell is a test where every collaborator is a scripted double and most assertions are about calls. Such a test mostly restates the implementation; it rarely fails for a real bug and often fails for a refactor.
Interview practice
You’re testing a CurrencyConverter that calls a third-party exchange-rate API over HTTP and caches rates for an hour. Which test doubles would you use for the API and the cache, and what would each test check?
Answer
The rate API is a query, so I’d stub it: a fake HTTP client (or an injected fetchRate function) that returns canned rates. That lets me test conversion and rounding with known numbers, and also force the failure modes I can’t produce on demand: a 503, a timeout, a malformed body. For those, the assertions are about what the converter does: return a stale cached rate if one exists, otherwise a clear error.
For the cache I’d use a fake, an in-memory store with the same interface as the real one, together with an injected clock, because the interesting behavior spans calls: the first conversion fetches, a second within the hour doesn’t, a third after the hour fetches again. To check “doesn’t fetch again”, I’d make the rate stub a spy and assert on its call count. That’s the one interaction worth verifying, because avoiding API calls (often rate-limited or billed per request) is the reason the cache exists. If the real cache is Redis, I’d run a short contract suite against both the fake and a real Redis instance, covering expiry in particular, since TTL handling is where an in-memory fake is most likely to differ.