Skip to content

Test Doubles: Dummy, Stub, Spy, Mock, Fake

The previous page, Testing Pure vs Impure Code, drew a line between code that only transforms its inputs and code that reaches out to the world — the clock, the disk, the network. Pure code is trivial to test because it has no dependencies to stand in for. Impure code is hard for exactly the opposite reason: to test it in isolation, you have to replace the real dependency with something you control.

The things you replace it with have a family name: test doubles. Just as a stunt double stands in for an actor when a scene is too dangerous, a test double stands in for a real collaborator when the real thing is too slow, too nondeterministic, too expensive, or not built yet. There are five distinct kinds, each with a precise job. Most people lump all five under one word — “mock” — and that vagueness quietly costs them: they reach for the heaviest, most brittle double when a two-line stub would do. This page names all five so you can pick the right one.

The umbrella term comes from Gerard Meszaros’s xUnit Test Patterns (2007), which named the family precisely to end the confusion. Test double is the genus; dummy, stub, spy, mock, and fake are the five species. They differ along two axes that are worth pinning down before we meet them:

Does it return canned data? Does it record/verify interactions?
─────────────────────────── ───────────────────────────────────
Dummy no (never called) no
Stub yes (fixed answers) no
Spy yes (often) yes — records, test asserts later
Mock yes yes — verifies ITSELF, fails the test
Fake yes (real logic) no — it genuinely works, in miniature

Two axes, five points. Read the table top to bottom and you can watch the doubles get heavier: a dummy does nothing, a stub answers, a spy remembers, a mock judges, and a fake actually works. Keeping these separate is not pedantry — the choice between “answers a question” and “judges an interaction” is the difference between a test that survives refactoring and one that shatters on contact with it.

A dummy is an object passed only to satisfy a signature — never actually used by the code under test. It exists because the compiler (or the function shape) demands something in that slot, and the test doesn’t care what.

Suppose a log-analysis function takes a Filter argument, but the specific test you’re writing exercises a code path where the filter is irrelevant — the input is empty, so nothing gets filtered either way. You still have to pass a filter. So you pass a throwaway:

// The test is about "empty input yields an empty report".
// The filter never runs, so any filter will do — pass a dummy.
#[test]
fn empty_input_produces_empty_report() {
let report = analyze(&[], Filter::default()); // Filter::default() is a DUMMY
assert!(report.is_empty());
}

Filter::default() here carries no meaning. Its whole purpose is to occupy the parameter slot so the call type-checks. If you swapped it for a different filter, the test would behave identically, because the filter is never consulted on empty input. A dummy is the lightest double: it has no behaviour at all, and the test asserts nothing about it. If you ever find yourself asserting on a dummy, it was never a dummy — it was a stub or a spy in disguise.

Stub: canned answers to feed the test a fixed input

Section titled “Stub: canned answers to feed the test a fixed input”

A stub goes one step further: it returns something. Its job is to feed the code under test a known, fixed value so the test can be deterministic. It’s how you turn an impure dependency — a clock, a config reader, a database query — into a predictable input.

The canonical example is time. Real code that reads the system clock is nondeterministic by construction (this is exactly the problem the pure vs impure page raised). A stubbed clock always returns the same frozen instant, so a test over “does this token expire correctly?” gets the same answer on every run:

class StubClock:
"""Always returns a frozen timestamp. Canned answer, no logic."""
def now(self):
return datetime(2026, 7, 9, 0, 0, 0)
def test_token_expires_after_five_minutes():
clock = StubClock() # STUB: feeds a fixed 'now'
token = issue_token(clock=clock) # token.expires_at = 00:05:00
assert token.expires_at == datetime(2026, 7, 9, 0, 5, 0)

The stub has one job — hand back 00:00:00 every time it’s asked — and it never fails a test. The test’s assertion is on the token, not on the clock. That is the defining feature of a stub: it participates in the arrange step, not the assert step. It sets up the world; the assertion checks the result. This is state verification — you check the state the code produced, not how it got there.

Spy: records how it was called, so you can assert afterward

Section titled “Spy: records how it was called, so you can assert afterward”

A spy is a stub that also remembers. It records the calls made against it — which methods, with which arguments, how many times — and stashes them so the test can inspect that record after the act. Where a stub feeds input, a spy captures output-as-interaction.

You reach for a spy when the behaviour you care about is the call itself. Consider a notify_on_error function whose entire job is to send an email when analysis fails. There is no return value to assert on — the observable effect is “an email was sent, to the right address, once.” A spy captures that:

class EmailSpy:
"""Records every send() call for later inspection."""
def __init__(self):
self.sent = []
def send(self, to, subject):
self.sent.append((to, subject)) # record, don't verify (yet)
def test_error_triggers_one_alert_email():
spy = EmailSpy()
notify_on_error(error="disk full", mailer=spy)
# ASSERT phase: interrogate the recorded interaction
assert len(spy.sent) == 1 # exactly once
assert spy.sent[0] == ("oncall@example.com", "ALERT: disk full")

Notice the shape: the spy is passive during the act — it just records — and the test does the asserting, in its own assert block, after the fact. This keeps the classic Arrange-Act-Assert structure intact and readable: arrange the spy, act, then assert on spy.sent. This style is called interaction verification (or behaviour verification): you’re checking what the code did to its collaborator, not what it returned.

A mock is a spy that has been told, in advance, what it should see — and it fails the test itself if reality doesn’t match. Where a spy records and lets the test check later, a mock carries the expectation inside it and verifies automatically. The assertion moves from the test body into the double.

def test_error_triggers_one_alert_email_with_mock():
mock = Mock()
# Expectation is declared BEFORE the act, baked into the double:
mock.send.assert_not_called() # (illustrative)
notify_on_error(error="disk full", mailer=mock)
# The mock enforces the expectation — it fails the test if violated:
mock.send.assert_called_once_with(
"oncall@example.com", "ALERT: disk full"
)

The distinction between a spy and a mock is subtle but real, and it’s mostly about where the verification lives:

SPY: double records calls → TEST asserts on the record (assert in test)
MOCK: double is told to EXPECT calls → DOUBLE fails the test (assert in double)

In practice, popular libraries blur this line — Python’s unittest.mock, Mockito, Jest, and Sinon all give you objects that can act as stub, spy, or mock depending on which methods you call on them. That’s fine; the value of the vocabulary is in your head, not in the class name. When you write assert_called_once_with, you are using the object as a mock — you’ve moved a piece of the test’s verification into the double, and you’ve coupled the test to the exact shape of an interaction.

Under the hood — why mocks are the most brittle double

Section titled “Under the hood — why mocks are the most brittle double”

Every double couples your test to something about the dependency. A stub couples you only to the dependency’s return type — swap the internals freely, the stub still hands back a value. A mock couples you to the dependency’s call signature and call sequence — the exact method names, argument order, and number of invocations. That coupling is precisely what makes a mock powerful (it can catch “we forgot to send the email”) and precisely what makes it brittle: a refactor that changes how the code talks to a collaborator — batching two send calls into one, renaming a method, reordering arguments — breaks the mock even though the observable behaviour is unchanged. This is the origin of the “mocks make tests fragile” complaint. The tests aren’t testing behaviour; they’re testing an implementation’s conversation, and conversations change under refactoring even when outcomes don’t.

Fake: a working, lightweight implementation

Section titled “Fake: a working, lightweight implementation”

A fake is the outlier of the five: it’s a real, working implementation of the dependency’s contract — just lighter than the production one. It has actual logic, actual state, actual correctness. It’s simply built for speed and control rather than durability.

The archetype is an in-memory store standing in for a disk- or network-backed one. In this repo’s kvlite, the real Db is backed by a write-ahead log on disk — durable, but slow to set up and tear down for every test, and it touches the filesystem. A fake would be an in-memory SharedStore: a plain hash map behind the same get/set/del interface, holding data in RAM for the lifetime of the test and vanishing when it ends.

Real dependency Fake
─────────────── ────
Db → write-ahead log on disk SharedStore → HashMap in RAM
durable, slow, touches FS correct, fast, disposable
(production) (tests)
Both satisfy the SAME contract: get / set / del

Because a fake genuinely works, tests against it read like tests against the real thing — you set a key and get it back and it’s there, because the fake actually stored it. You’re not scripting canned answers (that’s a stub) or counting calls (that’s a mock); you’re exercising real behaviour against a real (if miniature) implementation. Fakes shine when a dependency is used heavily across many tests: writing dozens of stub responses for a database is miserable and fragile, whereas one in-memory fake serves them all and stays honest as the interface evolves.

The decision rule: prefer state, reach for interaction only when it’s the behaviour

Section titled “The decision rule: prefer state, reach for interaction only when it’s the behaviour”

Five doubles is four too many to think about every time you write a test. Collapse the choice to one rule:

Prefer state verification (stubs and fakes). Reach for interaction verification (spies and mocks) only when the interaction is the behaviour you’re testing.

Most of the time, what you care about is the result: given this input, does the code produce the right output or leave the world in the right state? For that, feed inputs with stubs or fakes and assert on the outcome. These tests are robust — they survive refactoring, because they only pin down what the code achieves, not how.

Occasionally, the observable behaviour genuinely is an interaction with a collaborator, and there’s no state to check instead — sending the alert email, publishing the event, charging the card, retrying exactly three times. When “did it call the collaborator, correctly, the right number of times?” is the specification, then a spy or mock is the right tool, because the call is the thing you’re verifying.

What am I actually testing?
├── the RESULT / STATE ────────────► stub the inputs, or use a fake,
│ (return value, stored data) then assert on the outcome
└── the INTERACTION itself ────────► spy (assert in test) or
(an email sent, an event fired, mock (expectation in double)
a side effect with no result)

The trap the vocabulary protects you from is defaulting to mocks for everything. Mock a stub-shaped need and you’ve written a fragile test that asserts on a conversation nobody cares about — it breaks on every refactor and tests nothing anyone would notice in production. Name the five, and you’ll instinctively reach for the lightest double that does the job: a dummy when the slot is irrelevant, a stub when you need a fixed input, a fake when you need real behaviour cheaply, and a spy or mock only when the call itself is the point.

  • Why does it exist? Because impure code depends on collaborators that are slow, nondeterministic, costly, or unbuilt — and to test a unit in isolation you must replace those collaborators with controllable stand-ins. Test doubles are that replacement, named precisely so you pick the right weight.
  • What problem does it solve? It makes impure code testable and deterministic: a stubbed clock or an in-memory fake turns “depends on the world” into “depends on a value I control,” so a test gets the same answer every run without touching disk, network, or the real time.
  • What are the trade-offs? Every double is a lie you’ve agreed to tell, and the heavier the double, the more it couples the test to implementation. Stubs and fakes couple you only to a contract; mocks couple you to an exact conversation and break under refactoring — and every double can drift from the real dependency it imitates.
  • When should I avoid it? When the collaborator is fast, deterministic, and cheap — pure code, or a trivial in-process helper — a real object is simpler and more truthful than any double. And avoid mocks specifically when you only care about the result; a stub plus a state assertion is stronger.
  • What breaks if I remove them? Isolation. Without doubles, unit tests over impure code must use the real clock, disk, and network — so they become slow, flaky, order-dependent, and unable to reproduce the awkward cases (an expired token, a full disk, a timeout) you most need to test.
  1. Name the five test doubles and, in one phrase each, state the single job that distinguishes it from the others.
  2. You pass Filter::default() into a function under test, and the test never asserts anything about it. Which double is it, and what would have to change for it to become a stub or a spy instead?
  3. What is the precise difference between a spy and a mock? Point specifically to where the verification lives in each case.
  4. Give the decision rule for choosing between state verification and interaction verification, and give one concrete example where interaction verification is the right choice.
  5. Why is an in-memory SharedStore standing in for the disk-backed Db a fake and not a stub? What property does a fake have that a stub lacks?
Show answers
  1. Dummy — a placeholder passed to satisfy a signature but never used. Stub — returns canned answers to feed the test a fixed input. Spy — records how it was called so the test can assert on the interaction afterward. Mock — a spy with expectations baked in that fails the test itself if the expected calls don’t happen. Fake — a working, lightweight implementation (e.g. an in-memory store) with real logic.
  2. It’s a dummy: it only occupies a parameter slot so the call type-checks, and the test asserts nothing about it. It becomes a stub the moment the code actually reads a value from it and you script that value; it becomes a spy the moment you record calls made against it and assert on that record.
  3. A spy passively records calls and the test asserts on that record afterward (verification lives in the test body). A mock is told its expectations in advance and verifies itself — it fails the test from inside the double (verification lives in the double). The difference is where the assertion lives, not what it checks.
  4. Prefer state verification (stubs/fakes) and assert on the result; reach for interaction verification (spies/mocks) only when the interaction is the behaviour. A correct example: a function whose only job is to send an alert email on error — there’s no return value or stored state to check, so verifying “sent once, to the right address” is the specification.
  5. Because a fake is a real, working implementation of the same contract — you set a key and can get it back because it genuinely stored it in RAM — whereas a stub only returns pre-scripted canned answers with no real logic or state. The property a fake has and a stub lacks is actual behaviour: it responds correctly to any sequence of calls, not just the ones you scripted.