Low-level designCore8 min
Test doubles
A test double stands in for a real collaborator in a test: a stub answers, a fake works in a simpler way, a spy records, and a mock checks the calls it gets. Check what the code achieved rather than which calls it made, and tests survive refactoring.
A return desk and its test
ReturnDesk charges the rider through a PaymentGateway and sends a receipt through a Messenger.
1 · The situation
A bike return that charges and sends a receipt
At a bike-share dock, ReturnDesk.returnBike() works out the fare, $1.00 to unlock plus 10 cents a minute, charges the rider through a PaymentGateway and texts a receipt through a Messenger. Both are interfaces, so a test can pass its own versions. The gateway offers two ways to take money: authorize() then capture(), or a single charge(). The desk uses the first.
class ReturnDesk {
private final PaymentGateway payments;
private final Messenger messenger;
ReturnDesk(PaymentGateway payments, Messenger messenger) {
this.payments = payments;
this.messenger = messenger;
}
void returnBike(String rider, int minutes) {
int cents = 100 + 10 * minutes;
String authorization = payments.authorize(rider, cents);
payments.capture(authorization);
messenger.send(rider, "Ride of " + minutes + " min: " + money(cents));
}
private static String money(int cents) {
return String.format("$%d.%02d", cents / 100, cents % 100);
}
}Its test uses two stand-ins. MockGateway is told which calls to expect, records the calls it gets, and verify() compares them. SpyMessenger records the receipts it's asked to send. The checks themselves are written by hand here so all four languages run the same way; libraries do the same job in real projects.
class MockGateway implements PaymentGateway {
private final List<String> expected;
private final List<String> calls = new ArrayList<>();
MockGateway(String... expected) {
this.expected = List.of(expected);
}
public String authorize(String rider, int cents) {
calls.add("authorize " + rider + " " + cents);
return "A1";
}
public void capture(String authorization) {
calls.add("capture " + authorization);
}
public void charge(String rider, int cents) {
calls.add("charge " + rider + " " + cents);
}
String verify() {
if (calls.equals(expected)) return "passed";
String want = String.join(", ", expected);
return "FAILED, expected " + want + "; got " + String.join(", ", calls);
}
}Mock test: passed Receipt sent: Ana, Ride of 25 min: $3.50
2 · The change
A refactor that breaks a test, and nothing else
The payments team notices that two calls per ride doubles the gateway traffic, and the desk never does anything between them. They switch it to a single charge():
class ReturnDesk {
private final PaymentGateway payments;
private final Messenger messenger;
ReturnDesk(PaymentGateway payments, Messenger messenger) {
this.payments = payments;
this.messenger = messenger;
}
void returnBike(String rider, int minutes) {
int cents = 100 + 10 * minutes;
payments.charge(rider, cents);
messenger.send(rider, "Ride of " + minutes + " min: " + money(cents));
}
private static String money(int cents) {
return String.format("$%d.%02d", cents / 100, cents % 100);
}
}Ana is still charged $3.50 for 25 minutes, and still gets her receipt. The test fails anyway:
Mock test: FAILED, expected authorize Ana 350, capture A1; got charge Ana 350 Receipt sent: Ana, Ride of 25 min: $3.50
The receipt went out and the fare is right, yet the mock test fails.
The test didn't check that Ana paid $3.50. It checked that the desk made two particular calls in a particular order, which is how the first version happened to work. A test like that fails for correct changes and has to be rewritten with every refactor, so it stops protecting anything. Its failures become noise people learn to ignore.
The mock judges how the desk paid, not whether it did.
3 · The fix
Check what happened, not how
Start from the refactored version and ask what the test is for: Ana should be charged $3.50, once, and should get one receipt. Give the test doubles that can answer those questions:
FakeGateway really implements the gateway, in memory: authorizations wait in a map until captured, and every charge adds to the rider's total. Either way of paying ends in the same ledger, and chargedTo() reads it.
class FakeGateway implements PaymentGateway {
private record Pending(String rider, int cents) {}
private final Map<String, Pending> pending = new HashMap<>();
private final Map<String, Integer> charged = new HashMap<>();
public String authorize(String rider, int cents) {
String id = "A" + (pending.size() + 1);
pending.put(id, new Pending(rider, cents));
return id;
}
public void capture(String authorization) {
Pending p = pending.remove(authorization);
charge(p.rider(), p.cents());
}
public void charge(String rider, int cents) {
charged.merge(rider, cents, Integer::sum);
}
int chargedTo(String rider) {
return charged.getOrDefault(rider, 0);
}
}Ana charged $3.50: yes One receipt sent: yes Behavior test: passed
The same test also passes against the first version, the one that authorizes and captures. It checks the outcome both versions share, so the payments team can change how the desk pays as often as they like.
The desk depends on two interfaces. In the test, a fake and a spy fill them; in production, the real gateway and messenger do.
4 · The kinds
Five kinds of test double
A test double is any object that stands in for a real one during a test, as a stunt double stands in for an actor. They differ in what they do and in what the test asks of them afterwards:
Kind | What it does | In this story |
|---|---|---|
Dummy | Fills a parameter and is never used | A messenger passed to a test of the fare formula alone |
Stub | Returns canned answers to the calls the test expects | A gateway whose |
Fake | Works for real, with a shortcut that makes it unfit for production |
|
Spy | A stub that also records how it was called |
|
Mock | Is told which calls to expect, and fails the test when they don't match |
|
Behind the five sit two ways of checking a result:
- State verification looks at where things ended up: Ana's total in the fake's ledger.
- Behavior verification looks at the calls made along the way: authorize, then capture.
State checks survive any refactor that keeps the outcome. Behavior checks break whenever the calls change, which is right only when the calls are the outcome.
5 · When breaking it is fine
When checking the calls is the point
Mocks and spies have their place. Checking calls is right when:
- The call is the effect. Sending a receipt, publishing an event or writing an audit line leaves nothing in the code under test to inspect afterwards; the message itself is the result.
- The collaborator can't be faked cheaply. A legacy service with no clean interface may only be testable by checking what's sent to it.
- A call must not happen. "Don't charge a rider whose ride failed" is checked by asserting no charge call was made.
Situation | Usually the best double |
|---|---|
The code reads data it needs | A stub with the answers the test wants |
The code changes stored state | A fake, then check the state |
The code sends a message out | A spy, then check what was sent |
Exactly which calls happen is the requirement | A mock |
A parameter the test never uses | A dummy |
Fakes cost something too: a fake is code, and it can drift from the real thing. Run the same contract tests against the fake and the real implementation, so both behave alike where the tests rely on them.
6 · Getting it right
Double the roles you own
- Double interfaces you own. Wrap a third-party client behind your own small interface and double that; doubling a library's classes ties your tests to its insides.
- One reason to fail. A test that stubs five collaborators and verifies three call orders fails for many reasons and explains none of them.
- Don't double the thing under test. If a test replaces half of
ReturnDesk, it tests the replacement. - Keep doubles honest. A double that accepts calls the real object would reject hides bugs. Python's
create_autospec()makes a mock reject attributes and signatures the real object doesn't have.
Language | Hand-written doubles | Common libraries |
|---|---|---|
Java | Classes implementing the interface, as here | Mockito |
C++ | Subclasses of the abstract class | GoogleTest's gMock |
JavaScript | Plain objects with the right methods | The mock functions built into Jest and Vitest |
Python | Small classes, matched by duck typing |
|
7 · Look-alikes
Doubles and the ideas around them
Test doubles sit at the end of a chain of ideas.
Idea | Its part |
|---|---|
Seam | The place where a double can be plugged in |
Dependency injection | How the double gets there: passed in, as the desk's gateway is |
Test double | What gets plugged in |
In-memory repository | A fake for storage, the most common fake of all |
Contract test | Keeps a fake and the real thing honest with each other |
People often say "mock" for every kind of double, and mocking libraries create stubs and spies as well as mocks. The words matter when choosing what to check: a test that only needs an answer wants a stub, and gains nothing from expectations about calls.
8 · In practice
Doubles at the parking garage
In the parking-lot problem, the lot is given its pricing through an interface. A test of the lot can pass a stub pricing that always returns $5.00 and check the receipt, and a separate test can check each real pricing scheme with plain durations.
The checks recognize that shape as dependency injection: a class given its collaborator from outside instead of creating it. Design the parking lot so its tests can choose what the lot works with.
Check yourself
4 questions
Takeaways
Remember this
- A test double stands in for a collaborator: dummies fill slots, stubs answer, fakes work simply, spies record, mocks check expected calls.
- State verification checks the outcome; behavior verification checks the calls along the way.
- Tests that check exact calls break on harmless refactors; prefer outcomes unless the call itself is the behavior.
- Use spies or mocks for messages sent out and for calls that must not happen.
- Double interfaces you own, keep fakes honest with contract tests, and give each test one reason to fail.