GAP·MAP

Test doubles: mocks, stubs, spies

[ middle depth ]

The five test doubles

"Test double" is the umbrella term Gerard Meszaros coined (xUnit Test Patterns, 2007) for any object you swap in for a real collaborator, the way a stunt double stands in for an actor. The five differ on two questions: does it feed canned input, and does it verify the interaction.

  • Dummy: passed to fill a parameter list, never called. A placeholder User needed only to satisfy a constructor signature.
  • Stub: returns canned answers to steer the code down a chosen path (force an API to return an error, a clock to return a fixed time).
  • Spy: a stub that also records how it was called, so the test can assert after the fact (an email service that counts the messages it was sent).
  • Mock: pre-programmed with expectations about which calls it should receive, and it verifies those itself.
  • Fake: a working but lightweight implementation, such as an in-memory repository standing in for a database.

Only the dummy supplies no canned input. Only the mock has verification built in.

Mock vs stub: what you verify

The distinction is about what you assert, not how the object is built.

A stub drives state verification: set canned return values, run the code, then assert on the resulting state or output. You do not check whether the stub was called.

A mock drives behavior verification: pre-program the expected calls, run the code, then ask the mock to verify it received them. The assertion is about the interaction itself. Only mocks insist on behavior verification; the others usually use state verification (Fowler).

Wrong "A stub checks that my code called it correctly." A stub only supplies answers and asserts nothing about calls. The double that records calls is a spy, and the one that pre-programs and verifies expectations is a mock. Right stub for state, spy or mock for behavior.

When to reach for each

Reach for the least powerful double the test allows: a real object, then a fake, then a stub or spy, then a mock.

  • Dummy: only to satisfy a required parameter the code path never touches.
  • Stub: to control an indirect input (an error response, a feature flag, a fixed clock).
  • Spy: to confirm a side effect you cannot see through state, such as an analytics event firing.
  • Mock: when the interaction is the contract (publishing to a queue, a command that returns nothing meaningful).
  • Fake: when you want realistic behavior without the real dependency (an in-memory repository or queue).

Put doubles at architectural seams (network, disk, clock, a third-party SDK behind an adapter) rather than at internal collaborators.

What Jest and Vitest call a "mock"

A jest.fn() or vi.fn() is loosely called a mock function, but it is a spy plus stub hybrid: it records every call and can return canned values via mockReturnValue or mockResolvedValue, and you assert after the fact with toHaveBeenCalledWith. It is not a classic Meszaros mock with self-verifying expectations. jest.spyOn wraps an existing method and, by default, calls through to the original until you override it with mockImplementation.

[ senior depth ]

Over-mocking and brittle tests

Over-mocking couples a test to how the code works instead of what it produces, and that shows up as two failure modes.

A false negative: you refactor internals (rename a private method, reshape which helpers get called) and a green test goes red even though observable behavior is unchanged. Fowler is direct that coupling to the implementation interferes with refactoring, because implementation changes are far more likely to break these tests than state-based ones.

Over-specification makes it worse: pinning argument matchers and call order the test does not care about. Piling toHaveBeenCalledWith on incidental helper calls tests how the code runs rather than the output the caller sees.

A quieter failure is green-but-wrong. Mock expectations encode your assumptions, so an incorrect expectation can pass while the real behavior is broken.

Don't mock types you don't own

A hand-written mock of a third-party API freezes your understanding of it at the moment you wrote the mock.

Wrong "I'll mock the Stripe or fetch client directly so the test is fast and isolated." When the library changes its real behavior, your mock keeps the old behavior, so the test stays green while production breaks. That is a false positive, the most dangerous kind. Right use the real object, use a fake the library maintainers ship, or wrap the dependency behind your own thin interface and mock or fake that adapter. The adapter cordons the dependency to one place you control.

This is the London and GOOS rule (Freeman and Pryce), restated by Google's "Don't Mock Types You Don't Own."

Classicist vs mockist

Two schools, and the aliases surface in interviews.

Classicist (also Detroit, sometimes Chicago; state-based): use real collaborators where practical, a double only when the real thing is awkward, and verify through state. It tends to grow the domain model first (middle-out). Less coupled to implementation, so it supports more ruthless refactoring. The cost is that a bug in a shared collaborator can fail many tests at once, hurting fault localization, and fixtures get larger.

Mockist (also London; interaction-based): mock any collaborator with interesting behavior and verify through the calls. It drives design outside-in, starting at the entry point and discovering each collaborator's interface as you mock the layer beneath, which gives strong design feedback. It pinpoints a failure to one unit and keeps fixtures small. The cost is tighter coupling to implementation and more churn on refactor.

The industry has drifted toward classicist, state-based testing to cut brittleness (Thoughtworks published "Mockists Are Dead. Long Live Classicists"). The mature position is a trade-off, not a doctrine: use behavior verification where the interaction is the contract, and state verification everywhere else.

Spy vs mock, and where tools blur it

The spy and mock line is about timing. A spy is a stub that records calls, so the test runs the code and then asserts (assert-after). A mock carries expectations up front and self-verifies, so it can fail mid-run the moment it receives a call it was not told to expect.

Jest and Vitest collapse this. A jest.fn() or vi.fn() records calls and returns canned values and you check it afterward, so it behaves as a spy plus stub hybrid rather than a self-verifying mock. Vitest's docs advise asserting with expect(fn).toHaveBeenCalledWith(...) over reading raw .mock.calls, which keeps the test pointed at behavior instead of the double's internals.

beta

The interviewer part is in the works.

The diagnostic, personal maps, and AI mock interviews are being finished right now. The notes stay free either way. Leave an email and you'll get the first-cohort invite, plus a month of Pro when it opens.

builds on

was this useful?