Define stub, spy, mock, and fake. When would you use each, and what's the risk of over-using them?
Distinguishing the kinds of test doubles and the tradeoff between isolation and realism.
'Test double' is the umbrella term (like a stunt double) for any stand-in you swap for a real dependency in a test. The four common kinds differ by what they replace and what you assert. A STUB provides canned answers: it returns preset values so the code under test can run without the real dependency — 'when getUser is called, return {id:1}'. You use it to control inputs and avoid slow/nondeterministic dependencies; you don't assert on the stub itself. A SPY wraps a real (or empty) function and RECORDS how it was called — arguments, call count — while optionally letting the real one still run. You use it to verify an interaction happened ('the logger was called once with this error') without changing behavior. A MOCK is a double with pre-programmed EXPECTATIONS about how it should be called; the assertion is baked in — the test fails if the mock isn't called as specified. It's for verifying interactions are the contract. A FAKE is a working lightweight implementation — an in-memory database, a fake clock, an in-memory version of a repository — real enough to behave correctly but not production-grade. You use it when you need realistic behavior across many calls, not just canned returns. In practice the libraries blur these (jest.fn() can act as stub, spy, and mock), so the useful distinction is intent: am I controlling input (stub/fake) or verifying an interaction (spy/mock)? The over-use risk: MOCKING TOO MUCH couples tests to implementation (you assert internal calls, so refactors break tests) and reduces confidence (you tested against your assumptions of the dependency, which may be wrong — 'all your mocks pass but production is broken'). Prefer faking at real boundaries (network via MSW, time via fake timers) and using real collaborators inside the unit where cheap.
Choosing how to replace a dependency; explaining why heavy mocking makes a suite brittle.
// Stub: canned return, controls input to the code under test
const getRate = jest.fn().mockReturnValue(1.1);
// Spy: records calls, can keep real behavior
const spy = jest.spyOn(logger, 'error');
doThing();
expect(spy).toHaveBeenCalledWith(expect.stringContaining('failed'));
// Mock: assert the interaction is the contract
const pay = jest.fn();
checkout(cart, pay);
expect(pay).toHaveBeenCalledTimes(1);
// Fake: a real, lightweight implementation
const db = new InMemoryUserRepo(); // behaves like the real repo, no network