All topics
Testingintermediate

Spy and Mock Strategies

Compare spies, stubs, and fakes as test double strategies and when each is the right level of fidelity.

Not every test double needs to be built the same way, and interviewers ask about this to see whether you can reason about the right level of fidelity for a given test rather than reflexively reaching for the same mocking approach every time. A spy wraps an existing real function/method, letting it optionally still call through to the real implementation while recording how it was called (arguments, call count) — useful when you want to verify an interaction happened without necessarily replacing the underlying behavior.

A spy is like a hidden camera on a real cashier, recording exactly what happened without changing how the register works; a stub is like a vending machine rigged to always dispense the same snack no matter which button is pressed; a fake is like a fully functioning play-money economy used in a classroom simulation — realistic enough to practice with, but not connected to any real bank.

Key Concepts

1
A stub is a simpler replacement that returns a fixed, predetermined value regardless of input, useful when you don't care about verifying how it was called, only that the code under test receives a specific value to operate on (like a service method stubbed to always return a specific fake user object).
2
A fake is a more elaborate, working-but-simplified implementation — a fake in-memory UserRepository that actually stores and retrieves data from a plain array instead of a real database, behaving realistically enough for a test's purposes without any real persistence or network I/O — useful when a test needs the double to exhibit genuinely stateful, realistic behavior across several interactions rather than a single canned return value.
UserRepository
3
A thoughtful interview answer connects the choice of test double fidelity to what's actually being verified: over-specifying a test with exact call-count/argument assertions on every mocked dependency makes tests brittle and tightly coupled to implementation details rather than behavior, so the right general principle is verifying observable outcomes (return values, resulting state, thrown errors) wherever reasonably possible, and reserving strict call-verification (spies asserting toHaveBeenCalledWith) specifically for cases where the interaction itself — not just its outcome — is the actual thing being tested, like confirming an audit-logging call happened.
toHaveBeenCalledWith