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.
| Type | What it does | Verified in this chapter |
|---|---|---|
| Dummy | Passed to satisfy a signature, never actually called | DummyAuditLogger — zero methods, never invoked |
| Stub | Returns a canned answer, no real logic | StubNotificationSender.send() — always returns "SENT" |
| Fake | A real, working implementation — just not production-grade | FakeNotificationSender — a genuine in-memory inbox |
| Mock | Pre-programmed with an expectation, verified was met | MockNotificationSender.expect_call(), set before the action |
| Spy | Records what happened, inspected after the fact | SpyNotificationSender.calls — no expectation set up front |
Why Fakes Exist: A Measured Speedup
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.
The Real Danger: Mocks Can Drift From Reality
mock.expectation_met is True — test 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.
Where This Connects
| This chapter's finding | What it connects to |
|---|---|
| 443x measured speedup from a fake over real I/O | Software 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 dependency | Chapter 1's own "100% passing, still broken" composed-bug finding — the same failure mode, specific to test doubles going stale |
Hands-On Exercises
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).
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.
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.
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