Test Doubles: Dummies, Stubs, Fakes, Mocks & Spies

Software Testing Strategy

Chapter 4 · Test Doubles: Dummies, Stubs, Fakes, Mocks & Spies

"Mock" is the word most people reach for regardless of which of five genuinely different tools they mean. The distinction isn't pedantry — each category makes a different tradeoff, and picking the wrong one produces either a slower test than necessary or a test that can pass while production breaks. This chapter builds all five against one real system, then verifies both of those failure modes directly.

The Real Taxonomy, Applied to One System

UserRegistrationService depends on a NotificationSender to email a new user. Five genuinely different test doubles stand in for it below — same collaborator, five different jobs.

TypeWhat it doesVerified in this chapter
DummyPassed to satisfy a signature, never actually calledDummyAuditLogger — zero methods, never invoked
StubReturns a canned answer, no real logicStubNotificationSender.send() — always returns "SENT"
FakeA real, working implementation — just not production-gradeFakeNotificationSender — a genuine in-memory inbox
MockPre-programmed with an expectation, verified was metMockNotificationSender.expect_call(), set before the action
SpyRecords what happened, inspected after the factSpyNotificationSender.calls — no expectation set up front
Verified directly — all five behave as five genuinely different tools, not five names for one thing
All five tests pass, but for five different reasons: the dummy's own test never touches it; the stub's own test only checks the result, never the stub itself; the fake's own test asserts on real state the fake genuinely stored; the mock's own test verifies a pre-declared expectation; the spy's own test inspects a call history that was never pre-declared at all.
Mock vs. spy: before, or after
The practical difference between a mock and a spy is timing. A mock is told what to expect before the action runs, and the test asks "was the expectation met?" A spy records everything that happens with no prior expectation, and the test asks "what actually happened?" afterward. Both verify behavior rather than state — the distinction from a fake, which verifies state a real (if simplified) implementation actually produced.

Why Fakes Exist: A Measured Speedup

Verified directly — a fresh 443x measured speedup from swapping real file I/O for a fake
200 calls through a real dependency (writing to disk with a forced fsync, standing in for a real database write) took 277.41ms total — 1,387.04 microseconds per call. The identical 200 calls through FakeNotificationSender (a genuine in-memory list, no disk access) took 0.63ms total — 3.13 microseconds per call. 443× faster, measured directly.
Software Architecture Fundamentals already measured this at a larger scale
That course's own Chapter 7 measured a real ~18,111× speedup testing business logic through fake adapters instead of adapters simulating real I/O over a network. This chapter's own 443× is the same principle at a smaller, single-machine scale (disk I/O rather than network I/O) — the exact size of the gap depends on which real boundary is being avoided, but the direction and order of magnitude are consistent: a fake is fast because it genuinely does less work, not because it's cutting corners on correctness.

The Real Danger: Mocks Can Drift From Reality

class RealNotificationSenderV2: # the REAL class's interface changed in production def send(self, email, subject, body): ... class MockNotificationSender: # the mock was never updated to match def send(self, email, message): ... # still the OLD signature
Verified directly — the mock-based test stayed green while the real dependency crashed
The mock-based test: mock.expectation_met is Truetest passes. Running the identical register() call against the real, updated RealNotificationSenderV2 instead: crashed with TypeError: RealNotificationSenderV2.send() missing 1 required positional argument: 'body'. The test suite never noticed anything was wrong, because it never once exercised the real class — only a mock that had quietly stopped matching it.
This is Chapter 1's "100% passing, still broken" finding again, one layer deeper
Chapter 1 showed two individually-correct functions producing a broken composed result. This is a variant of the same failure: a mock can be internally self-consistent — its own expectation genuinely was met — while representing a reality that no longer exists. A mock verifies that code calls its dependency the way the mock expects; it says nothing about whether the mock itself still resembles the real dependency.

Where This Connects

This chapter's findingWhat it connects to
443x measured speedup from a fake over real I/OSoftware Architecture Fundamentals Chapter 7's own ~18,111x fake-adapter finding — same principle, different scale of boundary avoided
A green mock test alongside a crashing real dependencyChapter 1's own "100% passing, still broken" composed-bug finding — the same failure mode, specific to test doubles going stale

Hands-On Exercises

Exercise 1

Write a sixth test double for NotificationSender — a stub that simulates a failure by always returning None instead of "SENT" — and use it to test that UserRegistrationService.register() still correctly adds the user to its own list even when the notification "fails" (this chapter's own register() doesn't check the return value of send() at all).

📄 View solution
Exercise 2

Re-run this chapter's own fake-vs-real-I/O timing comparison with N = 1000 instead of 200. Verify the measured ratio stays in the same broad order of magnitude as the chapter's own 443x finding, and report the new per-call costs for both.

📄 View solution
Exercise 3

Fix this chapter's own mock-drift bug two ways: (a) update MockNotificationSender.send() to accept the new 3-argument signature, and (b) update UserRegistrationService.register() to actually call the new 3-argument signature. Verify the mock-based test still passes after both changes, and verify it now genuinely matches what RealNotificationSenderV2 expects.

📄 View solution

Chapter 4 Quick Reference

  • Dummy: satisfies a signature, never called — Stub: canned answer, no logic — Fake: real logic, shortcut implementation — Mock: expectation set before, verified after — Spy: no expectation, inspected after
  • Verified: a fake ran 443x faster than real file I/O for the same 200 calls — the same principle as Software Architecture Fundamentals' own ~18,111x fake-adapter finding, at a smaller scale
  • Verified: a mock-based test stayed green while the identical call against the real, updated dependency crashed with a TypeError — mocks verify internal consistency, not continued resemblance to reality
  • The real risk: mocks and fakes need to be kept in sync with whatever they stand in for, or they silently stop testing anything real
  • Next chapter: Integration Testing — testing where two real components actually meet, instead of doubling the seam away