Choose Between Mocks, Stubs, and Fakes Deliberately
Pick test doubles by the question each test answers: stubs for state setup, mocks for interaction verification, fakes for realistic behavior.
Task
Classify the test doubles in a suite, replace misused ones, and document the selection rule so future tests inherit it.
When to use this
Every dependency in the suite is a mock. Tests assert endless call sequences, break on every refactor, and verify interactions nobody specified.
Prerequisites
- A unit test suite with heavy mocking
- Refactoring time to realign tests with intent
- A short design doc to record the rule
Steps
1. Classify current doubles by what they assert.
rg -c "toHaveBeenCalledWith|mock\.ReturnValue" src --type ts | sort -t: -k2 -rn | head -5Expected output: A ranking of files by interaction assertions shows where mock overuse concentrates.
2. Replace interaction checks with state assertions where possible.
// before: verifies the call, brittle to any internal change
expect(emails.send).toHaveBeenCalledWith(user.email, expect.any(String));
// after: verifies the state the user cares about
const outbox = await emails.forUser(user.id);
expect(outbox).toHaveLength(1);
expect(outbox[0].to).toBe(user.email);
Expected output: The test now survives refactors that change how sending works internally, because it asserts the outcome.
3. Use a fake where behavior matters.
class FakeClock {
constructor(start = 0) { this.now = start; }
advance(ms) { this.now += ms; }
currentIso() { return new Date(this.now).toISOString(); }
}
Expected output: Time-dependent tests advance the clock explicitly, deterministic and instant, instead of sleeping or mocking Date internals.
4. Reserve mocks for specified interactions.
it("charges exactly once per checkout", async () => {
await checkout(cart);
expect(gateway.charge).toHaveBeenCalledTimes(1);
});
Expected output: The interaction itself is the contract here, so a mock is the right tool and the assertion is the specification.
5. Record the selection rule for the team.
// docs/testing-doubles.md
// Stub: prepare inputs/state. Assert outcomes elsewhere.
// Fake: deterministic stand-in with behavior (clock, repo).
// Mock: the interaction IS the spec (charge once, notify once).
Expected output: New tests inherit the vocabulary, and reviews gain a shared language for misuse.
Troubleshooting
Problem
Tests fail after harmless refactors
Solution
They assert call sequences instead of outcomes; convert them to state assertions through the public API of the unit.
Problem
Fake drifted from real behavior
Solution
Add a contract test suite that runs the same assertions against the real implementation and the fake, failing when they diverge.
Problem
Mock setup drowns each test
Solution
Excessive stubbing signals the unit under test has too many collaborators; extract a seam and test a smaller unit.
Limitations
- Fakes need maintenance to track real behavior; contract tests bound the drift but do not eliminate the cost.
- Interaction tests are legitimate where the interaction is contractual; dropping all mocks is as dogmatic as using only mocks.
The double answers the question
A stub answers what inputs exist, a fake answers how a dependency behaves, and a mock answers whether a specified interaction occurred. Misuse follows from picking the double before the question: mocking everything produces brittle call-sequence tests, and asserting nothing but setup produces tests that cannot fail meaningfully. Deciding the question first makes the choice mechanical.
// Interaction is the spec: mock is right
expect(gateway.charge).toHaveBeenCalledTimes(1);
// Outcome is the spec: state assertion is right
const outbox = await emails.forUser(user.id);
expect(outbox[0].to).toBe(user.email);
Refactor pain is the diagnostic
When a pure internal change breaks twenty tests, the tests were coupled to plumbing. Each such failure is a candidate for conversion: same scenario, but asserted through observable state. The suite becomes a description of behavior rather than a transcript of implementation, and refactors stop costing test debt.
Contract-test your fakes
A fake earns trust by behaving like the real thing, and that trust needs enforcement. Run a shared contract suite against both the fake and the real dependency wherever feasible: the tests that pass against both certify the fake. When the real implementation changes semantics, the contract suite fails and the fake gets fixed before your unit tests silently lie.