Test Doubles: Dummies, Stubs, Spies, Fakes & Mocks
A test double replaces a collaborator for a specific test purpose. Dummies fill unused parameters, stubs provide indirect inputs, spies record interactions, mocks verify expected interactions, and fakes implement a cheaper model whose drift from production is an explicit risk.
A test double is any object or process used in place of a production collaborator during a test. The useful question is not which mocking framework generated it, but what role the replacement plays in the experiment. The same object can act as a stub in one test and a spy in another; the role follows how the test controls and observes it.
The five roles #
| Double | Role in the test | Pass/fail comes from |
|---|---|---|
| dummy | fills a required parameter that is not used | somewhere else |
| stub | returns configured values or failures | state/output assertion on the subject |
| spy | records calls for later inspection | assertion over recorded interaction |
| mock | owns predeclared interaction expectations | mock verification |
| fake | provides a working but simplified implementation | ordinary assertions through its behavior |
A mock can also return canned data, and a spy can be generated by a framework. That does not erase the distinction: stubbing supplies indirect input; mocking or spying observes indirect output.
flowchart LR T["Test"] -->|direct input| S["Subject"] S -->|indirect output| D["Double at boundary"] D -->|stubbed response| S S -->|direct output| T D -->|record / verify interaction| T
Choose the observation that matches the contract #
Prefer state or output verification when the caller cares about the result:
const rates = new StubRates({ "EUR/USD": 1.08 });
const quote = new QuoteService(rates).price({ eur: 100 });
expect(quote.usd).toBe(108);Use interaction verification when the message itself is an externally meaningful obligation:
const audit = spy<AuditSink>();
await service.cancel(order, audit);
expect(audit.record).toHaveBeenCalledWith({
type: "order-cancelled",
orderId: order.id,
});Verifying that repository.get() was called before calculator.apply() usually freezes an implementation sequence. Verifying that a transaction committed once or that no notification was sent after failure may protect a real boundary contract. The difference is semantic, not syntactic.
Fakes are models, not faster realities #
An in-memory repository, local message bus or fake payment gateway contains working behavior, but it necessarily omits something. The omission is what makes it cheap and what makes it dangerous.
An in-memory map does not reproduce SQL collation, unique constraints under concurrency, transaction isolation, query translation, deadlocks or database error codes. A fake HTTP service may omit retries, authentication, streaming, malformed responses and rate limits. A test that passes against the fake proves behavior against the fake's model.
Use a fake when its simplified contract is exactly the abstraction the subject consumes. Add contract tests that run the same conformance suite against the fake and real implementation where drift would matter. Never substitute one database engine for another and silently call the resulting evidence equivalent; see real-dependency integration tests.
Put doubles at true boundaries #
Good seams include clocks, random generators, payment gateways, message publishers, filesystem ports and remote APIs. These have nondeterminism, cost, failure modes or ownership outside the unit. A double makes those indirect inputs controllable.
Doubling every neighboring class produces “solitary” tests whose object graph mirrors implementation structure. It also encourages a header interface with one production implementation and one mock. That interface does not represent substitutable domain behavior; it exists to satisfy the test tool. Prefer real in-process value objects and deterministic collaborators, then double the boundary that actually needs control. See interfaces and abstract types.
Dependency injection is one way to expose a seam, not a reason for a container. Constructor or function injection makes required collaborators explicit and keeps the enabling point at composition. A global service locator can be replaced in tests, but it hides dependencies and allows test state to leak through the process.
Failure simulation is a major benefit #
Production systems spend much of their complexity on errors that are hard to summon on demand. Stubs and fakes can deterministically produce:
- a timeout before any bytes arrive;
- a partial response followed by disconnect;
- an HTTP 429 with a specific retry hint;
- a duplicate message or idempotency conflict;
- a clock jump across a deadline;
- a disk-full or permission failure;
- a permanent decline versus a transient provider error.
The double must expose the same distinctions the production boundary exposes. A universal throw Error("failed") erases the contract and can make retry, accounting and user-visible behavior impossible to test correctly.
A decision procedure #
- Name the behavior under test and the real collaborator.
- Ask whether the real collaborator is fast, deterministic and safe in this scope. If yes, use it.
- If the test needs an indirect input, use the smallest stub or fake that supplies it.
- If the interaction is itself the contract, record or mock only that interaction.
- Declare what the double does not model.
- Add real integration or contract evidence for the omitted semantics that matter.
This prevents “mocking” from becoming a default architecture. A double is an experimental instrument: useful because it controls one boundary, trustworthy only within the model it actually implements.