Mocking
Mock at boundaries you have chosen to keep stable. Mock every internal collaboration and the suite becomes a cast of the implementation — and then it argues against the refactoring it was supposed to enable.
The requirement, the obvious build, and why it breaks
Every lesson starts where the work starts: someone asked for something, and the first implementation that comes to mind survives until the requirement changes.
Which collaborations deserve a mock, and which ones should the test simply let happen?
A PlaceOrder test constructs six mocks, asserts eleven call expectations, and is 90 lines long. Merging two of the collaborators — which always changed together — would not alter behaviour at all, and breaks the test entirely.
Isolate the unit completely. Every collaborator becomes a mock, every interaction gets an expectation, and the test then depends on nothing but the class under test. It is fast, deterministic and precise about failures.
The mock encodes your belief about the collaborator, and beliefs go stale. When the real collaborator changes its behaviour, the mock does not, and both test suites stay green while the system is broken — this is the central failure and it has no local symptom.
- The mock encodes your belief about the collaborator, and beliefs go stale. When the real collaborator changes its behaviour, the mock does not, and both test suites stay green while the system is broken — this is the central failure and it has no local symptom.
- The expectations describe the implementation.
expect(repo.save).toHaveBeenCalledAfter(policy.apply)is a statement about call order, which is exactly what a refactoring changes (What Refactoring Actually Is). - Merging two collaborators that always change together — a straightforwardly good move — becomes a large test edit, so it does not happen (Cohesion).
- The mock setup grows until it is a second, worse implementation of the collaborator, maintained by hand, with no tests of its own.
What limits the solution, and what must never stop being true
This domain leads with these two. A design that ignores its constraints is not a design, and an invariant nobody named is one nothing is protecting.
- The team writes tests first and is not going to stop; any advice has to survive that workflow.
- A mocking framework is already in use everywhere, so the cost of a mock is one line and the cost of a realistic object is ten.
- Some collaborators genuinely cannot be exercised — the payment gateway charges money.
- A structural change with no behaviour change must be able to pass the suite. If mocks make that impossible, the mocks have taken a position on the structure.
- A green suite must mean the pieces agree, not that each piece agrees with your belief about the others (Contract Tests).
Who owns what, and where the seams fall
Responsibilities decide boundaries; boundaries decide what an interface has to say.
- A mock owns exactly one thing: standing in for something outside the boundary that you cannot or should not exercise.
- The boundary owns the decision of what is outside. That decision is made in the design, not in the test file (What a Unit Is).
- The test owns asserting an outcome. Asserting an interaction is a fallback for when there is no observable outcome, and it should feel like a fallback.
- Mock at process edges and at deliberate, declared seams: the network, the clock, the payment provider, the message bus. Those are boundaries you already committed to keeping stable for design reasons.
- Do not mock across a seam you would happily move. If you would merge, split or rename these two classes without a second thought, they are inside one boundary and the collaboration between them is an implementation detail (Stable Boundaries).
- The rule of thumb: mock what you do not own, and what you own but deliberately declared. Everything else, let it run.
Two suites, both green, one broken system
The failure that matters most is not brittleness — that is annoying and visible. It is drift, and drift has no symptom at all until production.
When you mock something you do not own, the mock encodes what you believe it does. Nothing in your build ever compares that belief to reality. The consumer suite passes because the mock behaves as specified; the provider suite passes because the provider is internally correct; the integration is unverified by construction.
- The gap is closed by a contract test, not by better mocks (Contract Tests).
- It is also closed by not mocking — but across a process boundary that is not available, which is why the practice exists at service scale (What Changes at the Network Boundary).
- Inside one deployable, the gap is entirely self-inflicted: the real collaborator is right there and free to call.
The same test, mocked and not
Compare what each version would fail on. The mocked version fails if the call sequence changes; the real version fails if the outcome is wrong. Those are different promises, and only one of them is about behaviour.
const pricing = mock<Pricing>()
const stock = mock<Stock>()
const repo = mock<OrderRepo>()
const events = mock<Events>()
pricing.total.mockReturnValue(30)
stock.reserve.mockResolvedValue(true)
await placeOrder(cmd, { pricing, stock, repo, events })
expect(pricing.total).toHaveBeenCalledWith(cmd.lines)
expect(stock.reserve).toHaveBeenCalledBefore(repo.save)
expect(repo.save).toHaveBeenCalledWith(
expect.objectContaining({ total: 30, status: 'PLACED' }))
expect(events.publish).toHaveBeenCalledWith(
expect.objectContaining({ type: 'OrderPlaced' }))const repo = new InMemoryOrderRepo() // a fake with behaviour
const events = new RecordingEvents() // the event IS the requirement
await placeOrder(cmd, {
pricing: new Pricing(taxTable), // real
stock: new Stock(warehouse), // real
repo, events,
})
const order = await repo.byId(cmd.orderId)
expect(order.total).toBe(30)
expect(order.status).toBe('PLACED')
expect(warehouse.reservedFor(cmd.orderId)).toBe(2)
expect(events.types()).toEqual(['OrderPlaced'])The left version passes if Pricing computes tax incorrectly, because it never runs. It fails if you inline Stock into placeOrder, because it asserts call order. Both of those are backwards. The right version fails exactly when the order comes out wrong, and does not notice how the module is arranged internally — so a merge, a split or a rename inside the boundary costs zero test edits. The event assertion stays, because publishing OrderPlaced is a promise to other modules and not an implementation detail.
A rule you can apply in review
The useful question in a code review is not "is this a mock" but "what would have to change in production for this expectation to become wrong, and is that change one a user could observe?" If nobody outside could observe it, the test has an opinion it should not have.
The table is the version of that question you can apply without argument.
| Trigger | Symptom | Cause | Response |
|---|---|---|---|
| A double for the payment gateway | None — this is correct | You do not own it and cannot call it in a test | Keep it, and add a contract test or sandbox run so the belief is checked (Contract Tests) |
| A double for the clock | None — this is correct | Time is an input; controlling it is determinism, not isolation | Keep it. This is a production design improvement that happens to help tests (Time as a Dependency) |
| A double for the repository, in a rule test | A fake with a dictionary inside | The rule genuinely does not need persistence | Better: do not pass a repository to a rule at all. Pass the data (Functional Core, Imperative Shell) |
| A double for a pure calculator you own | pricing.total.mockReturnValue(30) | Isolation applied where there is nothing to isolate from — no I/O, no time, no shared state | Delete it and call the real one. It is fast and it makes the test check that the two agree |
| An expectation on call order between two of your classes | toHaveBeenCalledBefore | The test has taken a position on the implementation | Assert the resulting state instead, unless the ordering is externally observable (Temporal Coupling) |
| Six doubles in one test | 90-line setup | The unit has six collaborators — that is the finding, and it is about the production code | Read it as a responsibility problem, not a testing one (Single Responsibility, Carefully) |
How to build it
Most important first.
- Default to real collaborators inside the boundary. They are already tested, they are fast, and they make the test an actual check that the pieces agree.
- Where you must substitute, prefer a fake with real behaviour over a mock with expectations — an in-memory repository that genuinely stores and genuinely rejects duplicates fails when the code is wrong, rather than when the code is different (Test Doubles, Precisely).
- Assert outcomes. "The order is confirmed and one payment of £30 exists" beats "
chargewas called once with £30", because the first survives a refactor of how charging happens. - Reserve interaction assertions for cases where the interaction *is* the requirement: an email must be sent, an audit event must be emitted, a cancel must be propagated. There, the call is the behaviour.
- Pair every mock of something you do not own with a contract test against the real thing, or accept explicitly that the mock is unverified (Contract Tests).
- Count them. Six mocks in a test is not a testing problem to solve with better mocking; it is the unit telling you it collaborates with six things (God Object).
What the next change costs
The field this whole domain exists for. A structure is only better if it makes the change after this one cheaper — and it is worth saying which changes it does not help.
- Under mock-everything: merging two collaborators costs rewriting three test files and re-deriving eleven expectations, for zero behaviour change. Adding a collaborator costs a new mock in every existing test of that unit — the cost of a structural change grows with the number of tests, which is precisely backwards.
- Under mock-at-boundaries: the same merge costs nothing. Adding a collaborator inside the boundary costs nothing. Changing what the module *does* costs one test edit, which is the change being visible where it should be.
- What gets more expensive: a bug inside the module now surfaces as a failing module-level test rather than a failing class-level one, so you diagnose by reading rather than by filename. And an external contract change surfaces at the integration boundary, later than a mock would have surfaced your own signature change.
- Real collaborators mean slower tests and worse failure localisation. At large scale that cost is genuine and is the strongest argument the other side has.
- Fewer mocks mean more setup: building a realistic aggregate takes more lines than stubbing a method. Test-data builders reduce that but do not remove it.
- Interaction assertions, when they *are* the requirement, are the clearest way to express it. Avoiding them dogmatically produces convoluted outcome assertions that are worse.
What can go wrong
- Both sides pass and the system fails: the mock said the API returns
{ id }and it returns{ orderId }. Nothing in either suite can catch it (Contract Tests). - The suite becomes unmaintainable and the team's conclusion is "tests are a burden", which is true of these tests and gets generalised to all of them.
- The fix is over-applied — all mocks deleted, everything integration-tested — and the suite goes from brittle to slow, which is a different failure with the same effect: it stops being run.
- A verified fake is introduced and its verification quietly stops running, so it becomes an unverified mock with extra ceremony.
- Each mock creates a dependency from the test onto a specific shape of collaboration — a signature, an argument order, sometimes a call sequence.
- Mocks of things you do not own create a dependency on your *understanding* of an external system, which is the least reliable kind of dependency there is and the one nothing checks.
- Real collaborators inside the boundary create a dependency only on behaviour, which is what you wanted to depend on (Interface Versus Implementation).
- "Never mock." Mock the payment gateway. Mock the clock. Mock the partner API that rate-limits you. The argument is about *internal* collaborations, not about doubles as a technique.
- "Mocks are a code smell." A mock at a boundary is the correct tool. Six mocks in one test is the smell, and what it is telling you is about the production code (Long Parameter List).
- "Interaction tests are always wrong." When the interaction is the observable behaviour — an event published, an email sent, a cancellation propagated — asserting it is the only honest option.
- "Classicist testing means integration testing." It does not: the Chicago style still uses fast in-process tests with real objects. The disagreement is about doubles, not about speed (Where a Test Must Be Real).
- shotgun-surgery
- god-object
Testing it, and how it ages
- For every mocked external dependency, one contract test against the real thing, run on a schedule if it cannot run per-build (Contract Tests).
- For internal collaborations, no doubles at all — the test of the boundary covers them by construction.
- A periodic audit: count mocks per test. Any test above three is a design finding waiting to be read (Designing by Responsibility).
- Mock-heavy suites decay in a specific way: the mocks encode an older understanding of the collaborators, and nobody notices because nothing fails. The decay is silent, which is what makes it dangerous (Documentation Decay).
- As a module stabilises, its boundary stabilises, and boundary mocks get cheaper to justify. Early in a module's life almost nothing is stable enough to mock.
- Contract testing tends to arrive after the first cross-service incident, which is late but not too late — it is the mechanism that makes mocking safe at a service boundary (What Changes at the Network Boundary).
Where this applies
This domain's advice is contested more than most. These labels say what each claim is specific to — and where CONTESTED appears, the note gives the strongest form of the opposing view rather than a caricature.
- CONTESTEDThis is the London/Chicago split and both schools are serious. London (mockist, outside-in): mock every collaborator so each unit is specified in isolation; the test then documents that unit's contract with its neighbours precisely, failures point at exactly one class, and — the strongest part of the case — you can design a collaborator's interface before it exists, which is what makes true outside-in TDD possible. Chicago (classicist, state-based): use real objects and assert on resulting state; tests then verify that the pieces actually agree rather than that each agrees with a stub, and refactoring within the boundary is free. The empirical tiebreaker nobody has is how often mocks drift from reality versus how often coarse tests slow diagnosis. This lesson leans classicist for internal collaborations because drift is silent and slow diagnosis is loud — but that is a judgement about which failure you would rather have, not a demonstration that London is wrong.
- SCALE-SPECIFICInside one deployable unit, real collaborators are cheap and mocking them buys little. Across a service boundary the collaborator is not available at all, so a double is mandatory and the whole question becomes how to keep it honest — which is why contract testing is a service-scale practice and largely unnecessary in a monolith (The Modular Monolith).
- PARADIGM-SPECIFICThe whole debate presumes objects collaborating by message passing. In a codebase where the units are pure functions composed by a shell, there is nothing to mock inside the boundary — you pass values in and inspect values out — and the question collapses to "what does the shell talk to". That is a real advantage of that style, and it is not available to everyone.
Where the depth lives
This domain teaches the codebase-level structure and hands the rest off.
- — Testing & Reliability Engineering — mocking frameworks, spy verification APIs, snapshot testing and how to keep doubles maintainable are that domain's craft. This lesson is only about which collaborations should be doubled at all.
- — System Design — once collaborators are separate deployables, doubling is forced and the question becomes how consumer and provider stay in agreement across independent release cycles.