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.
When I substitute a collaborator, what exactly am I substituting — and what does that substitution stop the test from being able to detect?
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.
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.
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.
- 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.
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.
- 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.
- 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.
- 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.
- 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.
| Double | What it is | Fails the test when | What it makes you blind to |
|---|---|---|---|
| Dummy | A placeholder that is passed and never used | Never | Everything about that collaborator — which is fine, since it is unused |
| Stub | Returns canned answers, holds no state | Never — it can only let the test proceed | Whether your code wrote anything, wrote the right thing, or handled a failure it was never given |
| Fake | A working, simplified implementation | Your code is wrong — it saved the wrong value, or violated a constraint the fake enforces | Semantics the fake did not bother to implement: real constraints, real concurrency, real serialisation (Where a Test Must Be Real) |
| Spy | A real or stubbed object that records calls | You assert afterwards that a required interaction did not happen | Nothing structural — but an interaction you forgot to assert on is silently ignored |
| Mock | Pre-declared expectations, verified on exit | Your code is different — extra call, different order, different arguments | Whether 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.
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 constraint18 this.rows.set(o.id, structuredClone(o)) // real copy semantics19 }20 async byId(id: string) { return this.rows.get(id) ?? null } // real absence21}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.
| Trigger | Symptom | Cause | Response |
|---|---|---|---|
| A stub used for a stateful collaborator | Test passes while the code saves nothing, or saves the wrong object | A stub has no state, so a write goes nowhere and is unobservable | Use a fake as soon as the test cares whether something was written |
| A fake that only implements the happy path | Error handling has no tests, and is often absent from production code entirely | The fake never rejects, so callers were never forced to handle rejection | Give the fake failure modes and use them in at least one test (Failure-Aware Feature Design) |
| A fake that has drifted from the real implementation | Green suite, production failure, and nobody suspects the tests | Nothing compares the fake to the real thing | Run one contract suite against both (Contract Tests) |
| A strict mock in a unit that is being refactored | Failures on extra or reordered calls that change no behaviour | The expectation encodes the implementation | Convert to a spy plus an outcome assertion, or move the boundary outward (What a Unit Is) |
| A spy nobody asserts on | A test that looks thorough and checks nothing about the interaction | Spies are permissive by design | Assert, or delete the spy and use the real object |
| A shared fake edited to make one test pass | Forty unrelated tests turn red on an unrelated branch | The fake is a high-fan-in dependency of the suite | Treat 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,spyMailercommunicates 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
InMemoryOrderRepoused 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.
- 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.
- 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
- 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).
- 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).
- "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
- 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.
- 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.
- — 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.