TestingGENERALLANGUAGE-SPECIFICCONTESTED

Test Doubles, Precisely

Stub, fake, mock, spy and dummy are not synonyms. They differ in what they know and therefore in how they fail — and picking the wrong one is how a suite ends up brittle or blind.

The requirement, the obvious build, and why it breaks

Every lesson starts where the work starts: someone asked for something, and the first implementation that comes to mind survives until the requirement changes.

The question

When I substitute a collaborator, what exactly am I substituting — and what does that substitution stop the test from being able to detect?

The requirement

A code review comment says "use a stub here, not a mock". Nobody in the thread agrees on what either word means, and the discussion runs for forty comments without anyone naming what would actually go wrong.

The obvious build

They are all the same thing — objects that stand in for a real collaborator. The framework has one function for it, so the distinction is academic pedantry.

Why it breaks

They fail differently, and that is the whole point. A stub can only make the test pass; a mock can make it fail for reasons unrelated to behaviour. Using the second where you meant the first is how a suite becomes brittle.

How it breaks as requirements change
  • They fail differently, and that is the whole point. A stub can only make the test pass; a mock can make it fail for reasons unrelated to behaviour. Using the second where you meant the first is how a suite becomes brittle.
  • A fake with real behaviour catches bugs a stub cannot — a stub returning a fixed order will never notice that your code saved the wrong one, because it never stores anything.
  • A spy that records but does not constrain lets you assert after the fact, which reads far better than pre-declared expectations and is a different tool with a different failure mode.
  • Without the vocabulary the review conversation has nowhere to go: "this test is brittle" is a feeling, "this is a mock where a fake would do" is a specific, actionable claim.
RequirementConstraintsInvariantsResponsibilitiesBoundariesInterfacesStateDependenciesFailureImplementationTestsFeedbackEvolution

What limits the solution, and what must never stop being true

This domain leads with these two. A design that ignores its constraints is not a design, and an invariant nobody named is one nothing is protecting.

Constraints
  • The mocking framework in use calls everything a "mock", which actively obscures the distinctions.
  • The team has both mockist and classicist habits already in the codebase and neither is going away.
  • Any vocabulary has to be usable in a review comment in one sentence, or it will not be used.
Invariants
  • A double must fail the test when the production code is wrong, and must not fail it when the production code is merely different.
  • Whatever a double asserts about the collaborator must be true of the real collaborator, or the test is verifying fiction (Contract Tests).

Who owns what, and where the seams fall

Responsibilities decide boundaries; boundaries decide what an interface has to say.

Responsibilities
  • A dummy owns nothing: it fills a parameter that is never used.
  • A stub owns supplying canned answers so the code under test can proceed. It never fails a test.
  • A fake owns a working, simplified implementation — an in-memory store that genuinely stores, genuinely rejects duplicates, genuinely returns nothing for a missing key.
  • A spy owns recording what happened so the test can assert afterwards.
  • A mock owns a pre-declared expectation and fails the test when reality departs from it.
Boundaries
  • The choice of double is a statement about which side of the boundary the collaborator is on and how much of its behaviour matters to this test.
  • A fake is the only double that can encode semantics, so it is the right choice whenever the semantics are part of what you are testing (Where a Test Must Be Real).
  • A mock puts the interaction itself inside the tested contract, so it should only be used where the interaction is observable behaviour (Mocking).

Five words, and how each one fails

The useful axis is not what the double contains but what it can do to your test. Two of them can never fail a test; one fails when your code is wrong; one fails when your code is different. Choosing between those is the whole decision.

The last column is the one to read first — it is what the double is blind to, and therefore what you have decided not to check.

DoubleWhat it isFails the test whenWhat it makes you blind to
DummyA placeholder that is passed and never usedNeverEverything about that collaborator — which is fine, since it is unused
StubReturns canned answers, holds no stateNever — it can only let the test proceedWhether your code wrote anything, wrote the right thing, or handled a failure it was never given
FakeA working, simplified implementationYour code is wrong — it saved the wrong value, or violated a constraint the fake enforcesSemantics the fake did not bother to implement: real constraints, real concurrency, real serialisation (Where a Test Must Be Real)
SpyA real or stubbed object that records callsYou assert afterwards that a required interaction did not happenNothing structural — but an interaction you forgot to assert on is silently ignored
MockPre-declared expectations, verified on exitYour code is different — extra call, different order, different argumentsWhether the result was correct, since it usually asserts calls rather than outcomes (Mocking)

The same collaborator, four ways

Short versions of each, so the difference is concrete rather than definitional. Notice that they are not interchangeable: each one makes a different test possible and a different bug invisible.

Four doubles for one repository
1// STUB — canned answer, no state. Cannot fail.
2const stubRepo = { byId: async () => anOrder({ total: 30 }) }
3
4// SPY — real object, records what happened.
5const spyMailer = record(new Mailer(transport))
6await confirm(order)
7expect(spyMailer.calls('send')).toHaveLength(1)
8
9// MOCK — expectation declared up front, verified on exit.
10const mockRepo = expecting<OrderRepo>()
11mockRepo.expects('save').once().with(match({ status: 'PLACED' }))
12
13// FAKE — real behaviour, simplified. Fails when you are wrong.
14class InMemoryOrderRepo implements OrderRepo {
15 private rows = new Map<string, Order>()
16 async save(o: Order) {
17 if (this.rows.has(o.id)) throw new DuplicateOrder(o.id) // real constraint
18 this.rows.set(o.id, structuredClone(o)) // real copy semantics
19 }
20 async byId(id: string) { return this.rows.get(id) ?? null } // real absence
21}

Three details in the fake are the reason to prefer it: it enforces a constraint, it clones on write so a caller mutating its input cannot silently corrupt stored state, and it returns null rather than a convenient default for a missing row. Each one has caught a real bug that a stub returning anOrder() cannot (Optional Values and Absence).

How each one goes wrong in practice

Every double has a characteristic decay. Knowing which one you chose tells you which decay to watch for, and that is most of the practical value of the vocabulary.

The characteristic decay of each double
TriggerSymptomCauseResponse
A stub used for a stateful collaboratorTest passes while the code saves nothing, or saves the wrong objectA stub has no state, so a write goes nowhere and is unobservableUse a fake as soon as the test cares whether something was written
A fake that only implements the happy pathError handling has no tests, and is often absent from production code entirelyThe fake never rejects, so callers were never forced to handle rejectionGive the fake failure modes and use them in at least one test (Failure-Aware Feature Design)
A fake that has drifted from the real implementationGreen suite, production failure, and nobody suspects the testsNothing compares the fake to the real thingRun one contract suite against both (Contract Tests)
A strict mock in a unit that is being refactoredFailures on extra or reordered calls that change no behaviourThe expectation encodes the implementationConvert to a spy plus an outcome assertion, or move the boundary outward (What a Unit Is)
A spy nobody asserts onA test that looks thorough and checks nothing about the interactionSpies are permissive by designAssert, or delete the spy and use the real object
A shared fake edited to make one test passForty unrelated tests turn red on an unrelated branchThe fake is a high-fan-in dependency of the suiteTreat it as production code: review it, test it, and version its behaviour deliberately (Fan-in and Fan-out)

How to build it

Most important first.

  • Name them properly, including in variable names. stubClock, fakeRepo, spyMailer communicates in the identifier what the reader would otherwise have to infer from usage (Naming).
  • Default to fakes for stateful collaborators and stubs for stateless queries. Reach for mocks only when the call *is* the requirement.
  • Own your fakes. A shared, well-tested InMemoryOrderRepo used by two hundred tests is worth writing carefully — including its own test suite, and ideally the same suite the real implementation passes (Contract Tests).
  • Prefer a spy plus a post-hoc assertion over a mock with a pre-declared expectation: the same evidence, a much better failure message, and it does not fail on unrelated extra calls.
  • Keep the fake honest about failure. A fake repository that never throws teaches the caller that saving cannot fail, and the caller's error path is then untested and probably absent (Designing for Failure).

What the next change costs

The field this whole domain exists for. A structure is only better if it makes the change after this one cheaper — and it is worth saying which changes it does not help.

Cost of the next change
  • With fakes: adding a behaviour to the collaborator costs one change in the fake, and every test using it benefits. Removing a method costs one compile error in one place.
  • With scattered stubs: the same behaviour change costs an edit in every test that stubbed the old shape — a change whose cost scales with the size of the suite, which is the shape you want to avoid (Change Amplification).
  • With strict mocks: refactoring the collaboration costs re-deriving expectations across every test of the unit, for zero behaviour change.
  • The recurring cost of fakes is real and different: they must be kept faithful, and that is ongoing work that nothing forces you to do until something breaks.
What the recommended approach costs
  • Fakes cost real effort to write and to keep faithful. For a collaborator used in three tests, that effort is not repaid and a stub is genuinely the right call.
  • The vocabulary itself is a cost: five words to learn, and Meszaros's taxonomy is not universally used, so half the industry will call all of it "mocks" regardless.
  • Spies read better but permit sloppiness — an unasserted recorded call is invisible, whereas a strict mock would have complained. Some teams value that strictness and are not wrong to.

What can go wrong

Failure modes
  • The fake diverges from the real implementation and the suite becomes confidently wrong. This is the primary risk of fakes and the reason they need their own verification.
  • Mocks with strict expectations fail on extra calls that are harmless, so tests break during refactoring and everyone learns to loosen the expectations until they assert nothing.
  • Stubs return happy values only, so the failure paths are never exercised and the first real error in production takes an unguarded path (Swallowed Errors).
  • A fake grows features nobody needs until it is a second implementation with its own bugs, and debugging a test failure means debugging the fake (Speculative Generality).
Dependencies, and their direction
  • Every double is a dependency on your model of the collaborator. A fake makes that model executable and therefore reviewable; a stub leaves it implicit in scattered return values.
  • A shared fake becomes a dependency of the whole suite — a change to it can turn hundreds of tests red at once, which is a genuine coupling cost (Fan-in and Fan-out).
Misreads
  • "A fake is just a mock with more code." A fake has behaviour and no expectations; it fails when your code is wrong. A mock has expectations and no behaviour; it fails when your code is different. Opposite failure modes.
  • "Spies and mocks are the same." A spy records and lets you assert afterwards; a mock declares up front and fails on deviation, including on calls it was never told about.
  • "Use the strictest double available for safety." Strictness that fires on harmless differences is not safety; it is a false positive generator, and a suite that cries wolf gets loosened (Tone, Disagreement and Receiving Review).
  • "Fakes are only for tests." A good fake is often the best local development environment there is, and a payment sandbox is a vendor-supplied fake — it is a design artefact, not a test artefact.

Testing it, and how it ages

What to test, and at which boundary
  • Test the fake with the same suite the real implementation passes — a shared contract test — so divergence is a build failure rather than a production surprise (Contract Tests).
  • Include failure cases in the fake's repertoire: a duplicate key, a timeout, a not-found — otherwise callers are only tested on the happy path (Designing the Happy Path Last).
  • Assert on state where you can, on recorded interactions where the interaction is the requirement, and on nothing else.
How this design ages
  • Fakes tend to accumulate the semantics that mattered, one incident at a time, which makes a mature fake a surprisingly good written record of what the real dependency actually does.
  • Stubs do not accumulate anything; each one is local and forgotten, so knowledge about the collaborator never consolidates anywhere.
  • When a collaborator moves out of the process, its fake usually survives and becomes the basis of the consumer-side contract (What Changes at the Network Boundary).

Where this applies

This domain's advice is contested more than most. These labels say what each claim is specific to — and where CONTESTED appears, the note gives the strongest form of the opposing view rather than a caricature.

  • GENERALThe five categories come from Meszaros's xUnit patterns and describe what a substitute knows and asserts, which is language-independent even where the framework vocabulary is not.
  • LANGUAGE-SPECIFICIn dynamic languages a double needs no declared type and can be assembled ad hoc, so stubs are nearly free and fakes rarely feel worth writing; in a statically typed language a fake must implement the whole interface, which is more work up front but means a change to the interface fails to compile rather than failing silently at runtime. The taxonomy is the same; which double is cheap differs sharply.
  • CONTESTEDSome experienced practitioners argue the taxonomy is over-engineered — that in practice there are only "things that return values" and "things you assert on", and five names for that is a vocabulary tax. The strongest form of that argument is that the distinctions did not stop anyone writing brittle suites in the twenty years since they were named. The counter is narrow but real: the words let a review comment be specific enough to act on, and vague review comments about test quality reliably go nowhere.

Where the depth lives

This domain teaches the codebase-level structure and hands the rest off.

Domains that do not exist yet
  • Testing & Reliability Engineering — the frameworks, the argument matchers, verification modes, auto-mocking and fixture factories are that domain's territory. Here the doubles matter only as design statements about what a test is allowed to know.