Test Doubles & Isolation

Lesson 1 of 5

Mocks, Stubs, Spies & Fakes

"Once we start naming our test doubles precisely, we discover we had been reasoning sloppily about them all along." — after Gerard Meszaros, xUnit Test Patterns

Somewhere in your first week of writing tests, you hit a wall. You want to test a function, but the function calls a database. Or it sends an email. Or it charges a card, reads the system clock, hits a payment API halfway across the world. You cannot let the real thing happen inside a unit test — you would be slow, flaky, and possibly out five dollars per run. So you reach for the thing everyone calls a "mock," you swap out the real dependency, and your test goes green.

Then a senior engineer reviews your pull request and writes: "This isn't a mock, it's a stub — and here you actually want a spy." You blink. Aren't they all just fake objects? Why does the vocabulary matter when the test passes either way?

It matters because the words name different jobs, and confusing the jobs is how test suites rot. A test that verifies the wrong thing is worse than no test — it gives false confidence and breaks for the wrong reasons. In Volume 8 you built a Backend: services that call repositories, send notifications, talk to external APIs. Every one of those collaborators is a candidate for a test double — a stand-in you install during a test so the code under test runs in isolation. This chapter gives you the precise taxonomy, coined by Gerard Meszaros, that professional engineers actually use: dummy, stub, spy, mock, fake. Learn it once and you will read test code — and code reviews — with new eyes.

The big question

Everyone says "just mock it." But if a stand-in can return canned data, record how it was called, or assert on that record and fail the test itself — are those really the same thing, and does it change what your test is actually proving?

The umbrella term: test double

A test double is any object that stands in for a real collaborator during a test. The name is a deliberate pun on a stunt double in film: it looks enough like the real actor to play the scene, but it exists to do the dangerous or expensive work safely. When the code under test says "I need a PaymentGateway," the test hands it a double instead of the real thing. The code doesn't know the difference; that's the whole trick.

Meszaros invented the umbrella term precisely because the industry had collapsed five distinct ideas into the single overloaded word "mock." Under the umbrella sit five kinds of double, and they line up along one axis: how much intelligence and how much verification each one carries.

Rendering diagram…

Notice the split. Dummies, stubs, spies, and fakes are all about feeding input into the code under test — they help it run. Mocks are the odd one out: a mock is about verifying interactions — it carries expectations and can fail the test on its own. Keep that distinction in your pocket; the rest of the chapter unpacks it.

Dummy — the placeholder that is never used

A dummy is an object passed only to satisfy a parameter list or type signature. It is never actually called; its value never matters. You use a dummy when a function demands an argument you don't care about for this particular test.

Say your createOrder function takes a logger as its third argument, but the test you're writing exercises a validation path that returns before anything is ever logged. You still have to pass something. That something is a dummy — often just null, an empty object, or a throwaway jest.fn() you never assert on.

Everyday analogy

A dummy is the cardboard cutout of a person you put in the passenger seat to use the carpool lane. It fills the required seat. Nobody expects it to talk, navigate, or pay for petrol — it just needs to be there so the rule is satisfied.

Stub — canned answers on demand

A stub provides pre-programmed responses to calls made during the test. It has just enough logic to feed the code under test the input it needs to reach the state you want to examine. A stub says: "When you ask me for user 42, here is user 42." It does not care how many times you ask, or whether you ask at all.

Stubs exist to control indirect input — the data that flows into your code from a collaborator. If your getDiscount function branches on whether the customer is a premium member, you stub the membershipService to return "premium", run the function, and assert on the discount it computes.

Tests — Run & Watch Them Pass

Run to execute the tests…

The key insight: with a stub we assert on the return value of the function under test — its output, its resulting state. We never ask the stub "were you called correctly?" That question belongs to a different double.

Spy — a stub that keeps a diary

A spy does everything a stub does — it can return canned values — but it also records how it was called: the arguments, the number of calls, the order. After the code under test runs, you interrogate the spy's diary and assert against it.

This is the workhorse of everyday testing, and in Jest it is spelled jest.fn(). A jest.fn() is a function that remembers every call. You point it at the collaborator, run your code, and then check toHaveBeenCalledWith(...), toHaveBeenCalledTimes(n), or read mock.calls directly.

Tests — Run & Watch Them Pass

Run to execute the tests…

Here we assert on behaviour — "you called send with these arguments, exactly once." The spy is passive: it records but never complains. You, the test author, do the complaining afterwards with expect. That passivity is what separates a spy from a mock.

The engineer's notebook

In Jest, jest.fn() is technically a spy that can be dressed up as a stub. Give it .mockReturnValue(x) and it returns canned data (stub behaviour); leave it bare and it returns undefined but records every call (spy behaviour). One tool, two roles — which is exactly why the Meszaros vocabulary is worth learning: the tool blurs the line, but your intent should not.

Mock — the double that verifies itself

A mock is where things get strict. A mock is pre-loaded with expectations before the code runs: which methods should be called, with what arguments, how many times, in what order. It then verifies those expectations itself — a mock that was called wrongly will fail the test on its own, often before you write a single expect.

The difference from a spy is subtle but real. A spy records; you check afterward. A mock is told in advance what correct looks like, and it becomes the arbiter. In classic mocking libraries you'd write mock.expects("send").once().withArgs("ada@corp.io") — the expectation lives inside the mock, and a mock.verify() at the end blows up if reality diverged.

Jest doesn't have a separate "mock object" API in the old-school sense — its jest.fn() plays both spy and mock roles depending on how you assert. But conceptually the distinction is alive every time you write a test: are you recording and then checking (spy), or is the double's very configuration the specification of correct behaviour (mock)? The taxonomy is about intent, and intent survives whatever library you use.

Where this came from

The word "mock" in this precise sense comes from the 2000 paper "Endo-Testing: Unit Testing with Mock Objects" by Tim Mackinnon, Steve Freeman, and Philip Craig at Connextra in London. They were doing early Extreme Programming and needed to test code that talked to expensive collaborators. Meszaros later formalised the whole family in xUnit Test Patterns (2007), giving us the five names. So "mock" originally meant one specific thing — a self-verifying double with expectations — and only later got flattened by casual usage into "any fake object at all."

Fake — a real, working shortcut

A fake has a genuine, working implementation — it really does the job — but takes a shortcut that makes it unsuitable for production. The classic example is an in-memory database: it stores, retrieves, updates, and queries like the real thing, so code that uses it actually works, but it lives in a JavaScript Map and vanishes when the process ends. No network, no disk, no persistence.

Fakes are prized because the code under test exercises real logic against them. A stubbed repository returns whatever canned row you feed it; a fake repository actually stores what you save and returns it when you fetch — so a test that saves then reads back verifies the round trip, not just a hard-coded answer.

Tests — Run & Watch Them Pass

Run to execute the tests…

Fakes cost more to build but pay back handsomely: they don't break when you refactor internals, and they let you write sociable tests that exercise real collaboration. Many teams keep a hand-written in-memory fake of each repository beside the real one — a pattern you'll meet again when we discuss integration testing.

State verification vs behaviour verification

Step back and you'll see the five doubles cluster around two philosophies of what a test checks.

State verification Behaviour verification
Question asked "After running, is the result/state correct?" "Did the code call collaborators correctly?"
Doubles used Stubs, fakes, dummies Spies, mocks
Assertion target Return value, resulting object/state Recorded calls: args, counts, order
Breaks when Behaviour actually changes Implementation or behaviour changes
Risk May miss missed side-effects Couples the test to how, not what

State verification asks: I ran the thing — is the world in the right state? You use a stub or fake to set up inputs, then assert on outputs. Behaviour verification asks: I ran the thing — did it talk to its collaborators the way it should have? You use a spy or mock and assert on the recorded interaction.

Neither is universally right. When the effect that matters is a return value — a computed discount, a transformed payload — verify state. When the effect that matters is the interaction itself — an email got sent, a payment was charged, an event was published — you have no return value to inspect, so you must verify behaviour. A sendWelcomeEmail has nothing to return that proves it worked; the sending is the point, so you spy on the mailer. Choosing the wrong style is a top source of brittle tests, and we'll return to it hard in When Not to Mock.

In the real world

On the Backend from Volume 8, a CheckoutService both computes a total (state you can assert on) and calls paymentGateway.charge() (behaviour you must verify — there's no return value that proves money moved). A good test suite uses both styles on the same service: state verification for the total, a spy on the gateway for the charge. Real code rarely fits one box, and mature engineers reach for the right double per assertion, not per test file.

Common Mistakes

  • Calling everything a "mock." The word has been flattened until it means "any fake object," which is fine at the coffee machine but dangerous in code review. Say what you mean: if it returns canned data it's a stub; if you assert on its call record it's a spy. Precise words expose sloppy intent.
  • Using a spy/mock when a stub would do. If your assertion is about the return value of the function under test, you don't need to verify calls at all — a stub is enough. Reaching for toHaveBeenCalledWith when you could assert on output couples your test to the implementation for no benefit.
  • Verifying calls on a stub. Asserting toHaveBeenCalled against a collaborator whose job was merely to feed input turns a state test into a behaviour test by accident. Now a harmless refactor — caching the lookup, calling it twice — breaks a test that was never about the call.
  • Over-specifying mock expectations. Pinning down argument, order, and call count on every collaborator produces a test that mirrors the implementation line for line. It goes red on every refactor and green only when the code hasn't changed — which proves nothing.
  • Stubbing so much that nothing real runs. If every collaborator returns a canned answer, your "test" only checks that you wired constants together. Prefer a fake with real logic when you want confidence that the collaboration actually works.
  • Forgetting the dummy is never called. If you pass a null dummy and the code does touch it, you get a cryptic Cannot read properties of null. That crash is information: the parameter you thought was irrelevant to this path isn't. Reach for a stub instead.
  • Leaking doubles between tests. A spy created once and shared across tests accumulates call history. Recreate doubles in beforeEach (or call jest.clearAllMocks()), or toHaveBeenCalledTimes(1) will mysteriously see 3.
Thought experiment

You are testing archiveInactiveUsers(), which finds users who haven't logged in for a year and calls emailer.notify() for each. Suppose you write it with a stub emailer that returns true, and assert only on the count of users the function returns. The test passes. Now the emailer is silently broken and sends nothing. Does your test catch it? What kind of double, and what kind of verification, would you need so that "we forgot to actually send the emails" turns the test red?

Interview Corner

Interview Corner — the same idea at three levels. Answer yours; recognise the one above it.
Junior
What's the difference between a stub and a spy?
Model answer — A stub provides canned answers to calls so the code under test can run — I use it to control the input flowing in, then I assert on the function's output. A spy does that too but also records how it was called, so afterwards I can assert things like toHaveBeenCalledWith(...). In one line: a stub feeds input for state verification; a spy records interactions for behaviour verification. In Jest, jest.fn() plays both — bare it's a spy, with .mockReturnValue() it's also a stub.
Intermediate
When would you use a fake instead of a stub, and what do you gain and lose?
Model answer — I reach for a fake — say an in-memory repository — when I want the code to exercise real collaboration logic, not just consume constants. With a fake, a save-then-read-back round trip actually works, so the test survives internal refactors and catches integration bugs a stub would mask. The cost is effort: I have to build and maintain the fake, and keep it faithful to the real implementation or it drifts. I lose a little isolation and gain a lot of realism — a trade I'll happily make for anything with meaningful state.
Senior
Explain state vs behaviour verification and how it shapes which double you pick.
Model answer — State verification asks whether the resulting value or world-state is correct after running; I use stubs or fakes to arrange inputs, then assert on outputs. Behaviour verification asks whether the code interacted with collaborators correctly; I use spies or mocks and assert on recorded calls. The rule I apply: verify state when the effect that matters is a return value; verify behaviour only when the effect is the interaction itself and there's nothing to return — sending an email, charging a card, publishing an event. Over-using behaviour verification couples tests to how the code works rather than what it achieves, so they break on every refactor. I default to state, and reach for behaviour verification deliberately, only for genuine side-effects.
Company lens — how the same topic gets asked
micro1Live and AI-screened: expect to narrate out loud why you picked a stub over a spy for a given assertion, then actually write the jest.fn() and the toHaveBeenCalledWith. Communication of intent is scored as much as the code.
GoogleRigor about isolation and the "test the contract, not the implementation" principle. Expect a follow-up on how over-mocking makes tests non-hermetic-in-spirit — brittle to refactors — and when a fake is the disciplined choice.
MetaPragmatism: "which of these five would you actually bother with under deadline?" A strong answer: stubs and spies for speed, a shared in-memory fake where state matters, and don't gold-plate mocks you'll only refactor.
AmazonOperational framing via Dive Deep: "a bug shipped because a test stubbed the mailer and never checked it sent — how do you prevent that class of bug?" Talk behaviour verification for side-effects and fakes for round-trips.

Hands-on

Hands-on

Below is notifyOnLowStock(product, threshold, alerter): when stock dips below the threshold it should alert operations exactly once with the product's SKU. Finish the tests. Write one test that uses a spy to verify the alert was sent with the right SKU (behaviour verification), and one that verifies no alert fires when stock is healthy. Then add a stubbed alerter that throws, and prove your function still returns a sensible result — deciding for yourself whether that's the right design.

Tests — Run & Watch Them Pass

Run to execute the tests…

Summary

  • A test double is any stand-in for a real collaborator during a test; Meszaros' taxonomy splits it into five precise kinds.
  • A dummy fills a required slot and is never actually used. A stub returns canned answers to control input. A spy is a stub that also records how it was called. A mock is pre-loaded with expectations and verifies itself. A fake has real, working logic with a production-unsuitable shortcut (e.g. an in-memory store).
  • Stubs, dummies, and fakes feed input; spies and mocks verify interaction. That split is the beating heart of the taxonomy.
  • State verification asserts on the resulting value or state (use stubs/fakes); behaviour verification asserts on recorded calls (use spies/mocks).
  • Verify state by default; reach for behaviour verification only for genuine side-effects — sending, charging, publishing — where there's no return value to inspect.
  • In Jest, jest.fn() blurs spy and stub, so let your intent — not the tool — decide which role you're playing.
  • Precise vocabulary isn't pedantry: it exposes whether your test proves the right thing.

Naming your doubles is step one. Step two is designing code that even lets you swap them in — because a dependency you new up inside a function can never be replaced by a test. Next, in Dependency Injection for Testability, we make the seams that mocking depends on.

Editorial reflection

The five names look like trivia until the day a test lies to you. You'll refactor a method, watch a "mock" go red, and realise the test was never checking whether the code worked — only whether it was written the way it happened to be written that Tuesday. That's the moment the taxonomy stops being vocabulary and becomes judgement. The best testers I know don't reach for a mock reflexively; they pause and ask, "What am I actually trying to prove here — a value, or a conversation?" The answer picks the double. Learn to hear that question in your own head, and you've learned the real lesson of this chapter.

Q&A · 0

Enrol to ask questions and join the discussion.

No questions yet — be the first to ask.