AnyLearn
All lessons
Programmingintermediate

Test Doubles: Isolation and What It Costs

Replacing a real dependency with a stand-in is what makes a unit test fast, deterministic, and able to reach states you cannot otherwise produce. It is also how a suite ends up green while the system is broken. This lesson covers the five kinds of double, when each is right, and the two failure modes that follow every team who reaches for mocks by reflex.

Updated · AI-authored, review-gated · how lessons are made

Not signed in: your progress and quiz score won't be saved.
Progress1 / 8

Why anything gets replaced at all

A function worth testing usually depends on something inconvenient: a database, a payment provider, the system clock, a queue, another team's service. Running the real thing in a unit test is often impossible and always slow.

A test double is any stand-in for a real collaborator. The four honest reasons to reach for one:

  • Speed. In-memory beats a network round trip by three or four orders of magnitude, and that ratio is what makes the pyramid's base affordable.
  • Determinism. Real clocks, real randomness and real networks make tests that fail for reasons unrelated to the code.
  • Unreachable states. "The payment provider times out after charging the card" is a state you cannot conjure on demand, and it is exactly the state whose handling you need to verify.
  • Isolation of blame. When the test fails, the failure is in the unit, not somewhere down a chain of six real components.

Key idea: every double trades fidelity for control. You get a fast deterministic test, and in exchange the test no longer knows whether your assumptions about the real collaborator are true. Everything that goes wrong in this lesson is that trade, taken without noticing.

Full lesson text

All 8 steps on one page, for reading, reference, and search.

Show

1. Why anything gets replaced at all

A function worth testing usually depends on something inconvenient: a database, a payment provider, the system clock, a queue, another team's service. Running the real thing in a unit test is often impossible and always slow.

A test double is any stand-in for a real collaborator. The four honest reasons to reach for one:

  • Speed. In-memory beats a network round trip by three or four orders of magnitude, and that ratio is what makes the pyramid's base affordable.
  • Determinism. Real clocks, real randomness and real networks make tests that fail for reasons unrelated to the code.
  • Unreachable states. "The payment provider times out after charging the card" is a state you cannot conjure on demand, and it is exactly the state whose handling you need to verify.
  • Isolation of blame. When the test fails, the failure is in the unit, not somewhere down a chain of six real components.

Key idea: every double trades fidelity for control. You get a fast deterministic test, and in exchange the test no longer knows whether your assumptions about the real collaborator are true. Everything that goes wrong in this lesson is that trade, taken without noticing.

2. The five kinds, and the one distinction that matters

The vocabulary comes from Gerard Meszaros' xUnit Test Patterns, and teams use it loosely, but the categories are genuinely different tools.

DoubleWhat it doesTypical use
DummyPassed to satisfy a signature, never usedFilling a required argument
StubReturns canned answers to callsForcing a specific branch
SpyA stub that also records how it was calledChecking a side effect happened
MockPre-programmed with expectations; fails the test if they are not metAsserting an interaction protocol
FakeA real working implementation, simplifiedIn-memory repository, fake clock

The distinction that actually changes outcomes is not five-way. It is whether the double is used for state verification (run the code, then check the resulting values) or behaviour verification (assert that specific calls happened, in a specific way).

Stubs and fakes support state verification: they let the code run and you check what came out. Mocks and spies invite behaviour verification: you assert on the conversation between objects. The first survives refactoring. The second is coupled to the current design by construction, and that is the whole of the next two steps.

3. The same test, two ways

Concretely, here is one behaviour verified twice: a service that records an order and emails a receipt.

# Mock-heavy: asserts the conversation
def test_places_order_with_mocks():
    repo, mailer = Mock(), Mock()
    service = OrderService(repo, mailer)
    service.place(order)
    repo.save.assert_called_once_with(order)
    mailer.send.assert_called_once_with(order.email, "receipt")

# Fake-based: asserts the outcome
def test_places_order_with_fakes():
    repo, mailer = InMemoryOrders(), RecordingMailer()
    service = OrderService(repo, mailer)
    service.place(order)
    assert repo.get(order.id).status == "placed"
    assert mailer.last_to == order.email

The first pins the mechanism: two named methods, called once each, with those arguments. Rename save to persist, batch the two writes, or move the email behind a queue, and it fails though behaviour is unchanged.

The second pins the outcome: the order is retrievable and placed, the customer was mailed. Every one of those refactors passes untouched, and a genuine regression, an order that never gets saved, still fails it.

In practice: the fakes cost more to write once and less to own forever. That is usually the right trade for a collaborator you own and use in many tests.

4. Failure mode one: green tests, broken system

The deeper problem with doubles is not brittleness. It is that a double encodes your beliefs about the real thing, and beliefs can be wrong.

Predict first

Your test stubs the payment gateway to return {"status": "ok"} on success. Every test passes. In production, every payment fails. What happened?

The defences are structural, not cleverer mocking:

  • At least one test against the real thing, even if it is slow and runs only in CI: a sandbox account, a contract test, a nightly smoke test.
  • Record and replay, where a real interaction is captured once and replayed, so the fixture derives from reality instead of imagination.
  • Contract tests, where the provider runs the consumer's expectations against itself, so drift breaks the provider's build rather than the consumer's production.

5. Failure mode two: the suite that fights refactoring

The second failure is slower and more common. Mock-heavy tests make a codebase expensive to change, and the mechanism is worth stating precisely: a mock-based test is a second, executable copy of the design. Change the design and you must change both.

The symptoms are recognisable from any codebase that has been through it:

  • A behaviour-preserving refactor turns dozens of tests red, and "fixing" them means rewriting the assertions to match the new call graph.
  • Test setup grows longer than the code under test, because each mock needs its return values configured.
  • Developers stop refactoring, not from discipline but from cost, and design decay becomes structural.

Gotcha: the rule of thumb with the best track record is do not mock what you do not own. A double for a third-party client freezes your guess about someone else's API, and you will not be told when it changes. Wrap the third party in a thin adapter you do own, test the adapter against the real thing, and let the rest of your code depend on your interface, which you are allowed to have opinions about.

There is a real school of thought on the other side, and it is worth understanding rather than dismissing.

6. Two schools, and how to choose

The disagreement has names. The London school, sometimes called mockist, tests each unit in strict isolation with all collaborators doubled, and uses the tests to drive the design of the interfaces between objects. The Chicago or classicist school prefers real collaborators wherever they are cheap, doubling only at the awkward edges, and verifies state rather than conversation.

Both are coherent. The London style gives sharper failure localisation, since only one real object can be at fault, and it exerts genuine design pressure: painful mock setup is a signal that a class has too many collaborators. Its cost is the coupling this lesson has been about. The Chicago style gives tests that survive refactoring and catch integration mistakes between your own classes for free, and its cost is that a failure may implicate several objects at once.

A workable default, and the one most teams converge on with experience: use real objects inside your own module boundary, fakes for the things you own but are inconvenient (repositories, clock, queue), and mocks only at the true edges where you are asserting a protocol, such as "a receipt was requested exactly once".

flowchart TD
A["What is the collaborator?"] --> B["Your own cheap object"]
A --> C["Yours but inconvenient: DB, clock, queue"]
A --> D["Third party you do not own"]
B --> E["Use the real thing"]
C --> F["Write a fake you reuse everywhere"]
D --> G["Wrap in an adapter you own"]
G --> H["Test the adapter against reality"]
G --> I["Double your own interface elsewhere"]

7. The doubles that pay for themselves

Two specific fakes are worth building early in almost any system, because they remove whole categories of flaky and untestable code.

A fake clock. Code that calls now() directly is untestable at boundaries: expiry, retry backoff, rate limits, anything seasonal. Inject a clock and time becomes an input you control.

class FakeClock:
    def __init__(self, t): self.t = t
    def now(self): return self.t
    def advance(self, seconds): self.t += timedelta(seconds=seconds)

def test_token_expires_after_one_hour():
    clock = FakeClock(datetime(2026, 1, 1, 12, 0))
    token = issue_token(clock)
    clock.advance(3601)
    assert not token.is_valid(clock.now())

Without it, that test either sleeps for an hour or reaches inside the implementation to fake expiry, and the second option verifies nothing.

An in-memory repository. One fake implementing your storage interface, shared across the suite, turns hundreds of database-touching tests into millisecond tests. The discipline that makes it safe is running the same test suite against both the fake and the real implementation, so the fake is proven to honour the contract rather than merely being convenient.

That pattern, one interface with two implementations verified by a shared suite, is the cleanest answer to this lesson's central tension: full speed in the common case, real evidence that the double does not lie.

8. Doubles as a design signal

The last thing to take from test doubles is diagnostic rather than technical: difficulty in testing is usually information about the code, not about testing.

Recurring signals and what they mean:

  • This test needs eight mocks. The unit has eight collaborators. That is a design problem the test is reporting accurately.
  • I must mock a private method or patch an import. The dependency is hidden inside the unit instead of being passed in. Making it an argument fixes both the test and the coupling.
  • The fake keeps growing logic. Real behaviour is leaking into it. Either the interface is too broad, or that logic belongs in code you can test directly.
  • Every test needs the same twenty lines of setup. There is a missing abstraction, and the tests found it before the code did.

Key idea: testability is a property of design, not an activity performed on finished code. Code that is hard to test in isolation is usually code whose dependencies are implicit, and the fix that makes it testable, passing dependencies in rather than reaching out for them, is the same fix that makes it reusable and comprehensible.

With design and isolation covered, the next lesson changes the question entirely. Everything so far assumes you can think of the cases worth testing. Property-based testing is what you do when you cannot.

Check your understanding

The lesson ends with a 5-question quiz. Take it in the player above to see your score.

  1. What is the fundamental trade every test double makes?
    • Speed for readability
    • Fidelity for control: you gain a fast deterministic test and lose any evidence that your assumptions about the real collaborator hold
    • Coverage for maintainability
    • Isolation for parallelism
  2. Which distinction between doubles most affects whether tests survive refactoring?
    • Whether the double is hand-written or generated by a library
    • Whether it is a dummy or a spy
    • Whether it supports state verification (check resulting values) or behaviour verification (assert specific calls happened)
    • Whether it lives in the same file as the test
  3. All payment tests pass with a stub returning {"status": "ok"}, but every real payment fails. What does this illustrate?
    • The stub was not reset between test runs
    • A stub models your assumption about the dependency, so shared wrong beliefs make the whole suite reassuringly green
    • Stubs should always be replaced by mocks
    • The tests ran in the wrong order
  4. Why is "don't mock what you don't own" a durable rule?
    • Third-party libraries are usually already well tested
    • Mocking libraries cannot intercept external calls reliably
    • It reduces the number of test files
    • A double for someone else's API freezes your guess about it, and you get no signal when the real API changes
  5. What makes a shared in-memory fake safe to rely on across a large suite?
    • Running the same contract test suite against both the fake and the real implementation
    • Keeping the fake in the same package as the interface
    • Resetting it between every test
    • Limiting it to fewer than 100 lines

Related lessons

Programming
intermediate

What Is Worth Testing, and the Shape That Follows

Every test costs time to write, time to run, and time to maintain when the code moves. This lesson works out what that budget should buy: what each level of test can and cannot catch, why the pyramid has the shape it does, why some teams invert it, and the arithmetic that decides both questions before anyone argues about it.

8 steps·~12 min
Programming
intermediate

Benchmarks That Hold Up, and Knowing When to Stop

A benchmark is an experiment, and most are badly designed enough to produce confident wrong answers. This lesson covers what a measurement must control to mean anything, the ways microbenchmarks lie including code the compiler deletes, how to catch regressions in continuous integration despite noisy machines, and how to recognise the point where optimising stops paying.

7 steps·~11 min
Programming
intermediate

Flakiness, Coverage, and Suites That Survive

A test suite is a product with a maintenance cost, and two forces decide whether it stays useful: flakiness, which destroys the signal, and the metrics teams use to judge it, which mostly measure the wrong thing. This lesson covers where flakiness comes from, the arithmetic that makes it fatal at scale, what coverage research actually found, and what to measure instead.

8 steps·~12 min
Programming
intermediate

Property-Based Testing: Assert the Law, Not the Example

An example-based test checks the cases you thought of, which is exactly the set that excludes your bugs. Property-based testing inverts it: state a law the code must obey for all inputs, let the machine hunt for a counterexample, and let it shrink that counterexample to something you can read. This lesson covers the property patterns, generators, shrinking, and where the technique stops fitting.

7 steps·~11 min