Software Engineering · Testing

Stubs, Fakes, and Mocks: What Each Double Actually Verifies

The distinction between state verification and interaction verification, why a hand-written fake survives refactoring better than a mock, and what mock assertions couple.

A test double replaces a real collaborator so a unit can be exercised without its dependencies. That much is uncontroversial. What is controversial, and what the vocabulary hides, is that different kinds of double verify different things, and the choice of kind decides what a refactoring will break.

The taxonomy that matters has two axes. One is whether the double returns canned answers or implements behaviour. The other is whether the test checks the state left behind or the calls that were made. Confusing the axes produces the familiar pathology: a suite of thousands of tests, all green, none of which would notice if the collaborator’s semantics changed.

Four kinds of double, separated by behaviour

A stub answers calls with fixed values and nothing else. Given a repository stub that returns an empty list, the unit under test takes its “no records” branch. The stub is an input device.

A spy records the calls it received so the test can inspect them afterwards. Spies are stubs with a log; they support assertions without dictating them at configuration time.

A mock is configured with expectations before the interaction and fails the test if they are not met — an output device that asserts. The distinction from a spy is not mechanical but temporal: a mock’s expectations are part of the arrangement and are checked by the framework, while a spy’s record is examined by the test body.

A fake is a working implementation with a shortcut that makes it unsuitable for production: an in-memory repository instead of a database, a clock that the test can advance, a filesystem backed by a map. Fakes contain logic, and therefore contain bugs, and therefore need their own tests.

Two further doubles are usually listed and matter less. A dummy is passed to satisfy a signature and never used. A partial double replaces some methods of a real object and delegates the rest, which is useful for narrow overrides and dangerous because the unreplaced methods run real code with real side effects.

The rule that follows from the definitions: a fake is verified by state, a stub is an input, and a mock or spy is verified by interaction. Only two of those four are asserting anything about the unit’s behaviour, and they assert different things.

State verification versus interaction verification

Consider a service that closes an account: it must mark the account closed, cancel subscriptions, and send a notice. Two tests can be written.

The state-verifying test supplies a fake repository, runs the operation, and asserts that the stored account has status closed and that the fake subscription store holds no active subscriptions for that account. The assertions are about the world after the call. Any sequence of internal calls that produces that world passes.

The interaction-verifying test supplies a mock repository and a mock notifier, runs the operation, and asserts that save was called once with a closed account and that notify was called once with the account’s owner. The assertions are about the conversation. Any final state that resulted from that conversation passes, including a state the test never inspects.

The two are not equivalent, and neither dominates.

Interaction verification is precise about protocol. If the contract with the notifier is “exactly one notice per closure, never on retry”, only an interaction assertion can state that; the state after the call cannot distinguish one notice from three. Protocol requirements — idempotency keys, ordering, batching, backoff — are interaction-shaped facts and need interaction-shaped checks.

State verification survives restructuring. If the service stops calling notify directly and instead enqueues a job that a worker drains, the state test still passes once the fake queue is drained; the mock test fails, and the failure is about the call graph rather than about the account being closed. Every refactoring that preserves behaviour but changes the internal conversation invalidates the mock-based tests, and the invalidation is indistinguishable at review time from a genuine defect.

The cost asymmetry explains the widely reported experience that mock-heavy suites resist refactoring. The suite is not wrong; it is measuring a property the author did not intend to freeze.

Why a hand-written fake usually beats a generated mock

A fake is written once, against the same interface the production implementation satisfies, and reused by every test that needs that collaborator. It can implement real semantics: an in-memory repository that enforces unique keys, a clock that advances only when told, a queue that preserves order.

Three consequences follow. First, the fake’s behaviour is visible and reviewable as ordinary code, rather than distributed across dozens of per-test expectation setups. Second, its semantics can be made stricter than production — the fake can reject writes with duplicate keys even when the production store would accept them, surfacing a bug the production configuration would hide. Third, the fake is a single point of maintenance: when the interface changes, one file changes, not two hundred tests.

The cost is that the fake must be correct. An in-memory repository that ignores its transaction boundary will let tests pass that would fail against a real database; a fake clock that shares state across tests produces order-dependent results. Fakes need their own small test suite, run against the same interface the production implementation satisfies, which is exactly the shape of a contract test.

Generated mocks, in contrast, are cheap per test and expensive in aggregate. Each test’s expectations are a local, informal restatement of the collaborator’s contract, and nothing checks that all the restatements agree. When the contract changes, the compiler does not complain; each test fails separately, and the failures are discovered one at a time.

interface Account {
  id: string;
  email: string;
  status: 'active' | 'closed';
}

interface AccountStore {
  save(account: Account): Promise<void>;
  get(id: string): Promise<Account | undefined>;
}

// A fake with real semantics: it rejects the same transition the production
// constraint rejects, and records nothing about how it was called.
class InMemoryAccounts implements AccountStore {
  private rows = new Map<string, Account>();

  async save(account: Account): Promise<void> {
    const existing = this.rows.get(account.id);
    if (existing && existing.email !== account.email) {
      throw new Error(`email change requires verification: ${account.id}`);
    }
    this.rows.set(account.id, { ...account });
  }

  async get(id: string): Promise<Account | undefined> {
    return this.rows.get(id);
  }

  all(): Account[] {
    return [...this.rows.values()];
  }
}

The save method rejects a state transition the production schema also rejects. That check exists in the fake, which means every test that closes an account also tests the transition rule — for free, and without any assertion in the test body.

What mock assertions actually couple

An expectation like “save was called once with an object whose status is closed” couples the test to four separate decisions: that save exists, that it is called rather than some other method, that it is called once, and that the argument carries the field in that position. Only the last is a statement about the domain. The first three are statements about the current implementation, and any of them may change for good reasons.

Argument matching has its own trap. Exact-object matchers couple to every field, including fields the test does not care about, so adding a field to a record breaks unrelated tests. Loose matchers — “any object” — restore stability by giving up the assertion’s content. The usual resolution is a matcher on the two or three fields that matter, which is fine as long as the fields chosen are the contract rather than the current call site’s convenience.

Verification order and counts are the sharpest form of coupling. “Called before” asserts a sequencing the specification may not require; “called exactly once” forbids a legitimate retry or a batched implementation. Both are worth asserting when the specification says so, and both are pure liability otherwise.

Choosing a double deliberately

The decision procedure that follows from the above is short.

Use a stub when the collaborator’s return value is what the test needs and the collaborator’s behaviour is irrelevant: a lookup that yields a fixed record, a feature flag that is off.

Use a fake when the collaborator has semantics the unit depends on: a store with uniqueness, a clock with a notion of now, a queue with order. Write it once, keep it small, test it.

Use a spy or mock when the interaction is the contract: a payment authorization that must not be retried, an email that must be sent exactly once per event, an ordering guarantee between two writes. Scope the expectation to the fields the contract names.

Never use a mock for a collaborator whose real implementation is cheap and deterministic. Replacing a pure function with a mock converts a real check into a restatement of the call, and the restatement cannot be wrong in an interesting way.

The choice interacts with test scope. A test that exercises a wide slice of the system needs few doubles and gets strong oracles from real behaviour, at the cost of seconds rather than milliseconds per case — the trade-off that suite economics puts a price on. A test that exercises one function needs doubles for everything it calls, and its oracle is only as good as the assertions the author wrote, which is the subject of test oracles.

The failure mode of a doubled suite

The characteristic failure is a suite in which every unit is tested against doubles that were written to match the unit’s expectations, so the whole system is verified against a set of assumptions that no one has checked against reality. Each layer’s tests pass; the composition fails on the first integration attempt because the layers disagree about a contract that only the doubles ever implemented.

The counterweight is not to abandon doubles but to anchor them. A contract test run against both the fake and the real implementation catches divergence at the interface. A small number of tests with no doubles at all — the real store, the real clock, a real process — catches divergence in the wiring. The doubles then do what they are good at: making the bulk of the cases fast, deterministic, and precise about the one behaviour each test names.

References

  1. Mocks Aren't Stubs — Martin Fowler
  2. xUnit Test Patterns: Refactoring Test Code — Gerard Meszaros; Addison-Wesley
  3. Endo-Testing: Unit Testing with Mock Objects — Mackinnon, Freeman, Craig; XP2000