Two tests produce the same checkout quote. One builds five Mockery doubles, specifies every argument, fixes their global call order, and makes a serializer mock return the expected JSON. The other resolves the use case through Laravel’s container, replaces one HTTP catalogue, and asserts the JSON emitted by the real policies and serializer.
Both are green. That tells us they agree with today’s code. It does not tell us what either test will reject tomorrow.
The useful question is not how many mocks feel excessive. It is which defect mechanisms disappear when a collaborator is replaced. A double earns its place when it controls a deliberate boundary. Replace the owned collaboration graph, and a test can become strict about a harmless call sequence while staying blind to defects in the code that never ran.
Five doubles turn the collaboration into a script
The example is a small, synthetic Laravel checkout flow:
BuildCheckoutQuote
├── ProductCatalogue external HTTP gateway
├── DiscountPolicy owned deterministic policy
├── TaxPolicy owned deterministic policy
├── ShippingPolicy owned deterministic policy
└── QuoteSerializer owned output collaboratorThe use case obtains a price, calculates a discount, tax, and shipping, then serializes the composed quote. Its mock-heavy test replaces all five collaborators. A shortened part of the setup looks like this:
$taxes
->shouldReceive('taxCents')
->once()
->with(9_000, 'FI')
->globally()
->ordered()
->andReturn(1_800);
$shipping
->shouldReceive('shippingCents')
->once()
->with('FI')
->globally()
->ordered()
->andReturn(500);
$serializer
->shouldReceive('serialize')
->once()
->globally()
->ordered()
->andReturn($expectedJson);This is genuine behavior verification. PHPUnit describes a
mock as an observation point for calls and communication, while a stub controls
indirect input. Mockery documents that ordered() follows
declaration order and that globally() extends that sequence across mock
objects.
Nothing is mechanically wrong with those expectations. The problem is the contract they select. Tax and shipping both depend on the discounted subtotal and destination; neither consumes the other’s result. The quote contract does not say which calculation runs first, yet the test does.
A harmless reorder fails the fortress
The first counterfactual swaps the two independent calculations:
$shippingCents = $this->shipping->shippingCents($destination);
$taxCents = $this->taxes->taxCents(
$discountedSubtotalCents,
$destination,
);The reproduction runs the application before and after that change and compares the complete output byte for byte. Both executions emit:
{"sku":"CHAIR-BLACK","subtotal_cents":10000,"discount_cents":1000,"tax_cents":1800,"shipping_cents":500,"total_cents":11300}The collaboration test stays green. The mock fortress stops before making an assertion:
Mockery\Exception\InvalidOrderException:
Method taxCents(9000, 'FI') called out of order:
expected order 3, was 4The retained run measured 238 milliseconds for that expected failure and 259 milliseconds for the green collaboration test. Those figures include starting a disposable Docker container. They identify the exact runs; they are not a speed comparison.
The failure is worse than ordinary refactoring friction. It reports a broken sequence even though the observable quote did not change. The test has promoted an implementation choice into a contract.
Real collaborators leave real defect mechanisms in the room
The second test keeps the external catalogue under control but lets Laravel compose the owned code:
$container = new Container();
new CheckoutBindings()->register($container);
$container->instance(
ProductCatalogue::class,
new FixedProductCatalogue(10_000),
);
$result = $container
->make(BuildCheckoutQuote::class)
->for('CHAIR-BLACK', 'FI');
self::assertSame($expectedJson, $result);The production bindings connect DiscountPolicy, TaxPolicy,
ShippingPolicy, and QuoteSerializer to their real implementations.
Laravel’s container documentation defines those
interface-to-implementation bindings and instance replacements. The
replacement here follows the same mechanism Laravel documents for
injected test doubles, but it supplies a small fixed
implementation rather than a Mockery expectation.
Resolving through the container widens the observation boundary deliberately. It includes production wiring and serialization because both can change the quote. It still excludes the catalogue’s HTTP transport because a remote provider is unnecessary for this decision.
Now seed a defect in the real serializer. Instead of reading taxCents, it
writes shippingCents into the tax_cents field:
'tax_cents' => $quote->shippingCents,The total remains 11300, so an assertion against the total alone would miss
the error. The actual JSON changes to:
{"sku":"CHAIR-BLACK","subtotal_cents":10000,"discount_cents":1000,"tax_cents":500,"shipping_cents":500,"total_cents":11300}This time the result crosses the other way. The fortress passes in 232
milliseconds because its serializer double still returns the prewritten JSON.
The collaboration test fails in 234 milliseconds and shows the exact field
change from 1800 to 500.
That is the blind spot made visible. The mock did not produce a false answer; it removed the defective serializer from the experiment. Its green result can only speak about the call and canned return value that remained.
The catalogue stub survives the same test
Replacing fewer collaborators is not the goal. Each replacement needs a reason.
The real catalogue adapter issues an HTTP request to a remote provider. For
this quote test, the relevant input is a deterministic price.
The fixed implementation satisfies the production ProductCatalogue port and
returns only 10_000; it does not reproduce the discount policies or hand the
test a finished quote.
That makes it a narrow control over an external input. Running the HTTP adapter would add network and provider behavior without helping either counterfactual. The adapter still needs a separate contract test capable of detecting a changed payload. A fixed implementation cannot prove that the real provider and adapter remain compatible.
This is why “mock nothing” is no better as a rule than “mock every dependency.” The boundary follows the defect under examination. The preceding article on choosing test boundaries from defects applied that rule across processes and storage engines. Here it applies inside one object graph.
Strict interaction checks belong to interaction contracts
There are collaborations where order is observable behavior. A transaction must commit before a message is acknowledged. A lock must be released. A retry budget must stop. A protocol may require authentication before a command.
For those cases, an ordered expectation can state the contract more directly than inspecting final state. The deciding difference is that reversing the calls changes externally meaningful behavior. The checkout policies were chosen precisely because their order does not.
Focused unit tests also keep their place. A tax policy with many rate and rounding partitions should not force every example through the full checkout collaboration. Test that policy directly. Add the composed test for risks created by wiring, data flow, and serialization. One does not have to absorb the other.
The recommendation reverses toward a double when the real collaborator is slow, nondeterministic, destructive, unavailable, or externally owned. It reverses toward a wider integration test when persistence, framework behavior, or process communication creates the named risk. This is a boundary decision, not a purity contest between testing schools. Fowler’s distinction between classical and mockist testing is useful history; the fixture tests a narrower operational question about what each boundary can observe.
Map the doubles before deleting them
When a test starts to resemble the five-mock version, classify each double before rewriting anything:
| Target | What replacing it removes | Decision in this test |
|---|---|---|
| HTTP catalogue | remote availability and payload | keep a fixed port implementation |
| discount policy | owned price calculation | run the real policy |
| tax policy | owned tax calculation | run the real policy |
| shipping policy | owned shipping calculation | run the real policy |
| quote serializer | owned output mapping | run the real serializer |
| Laravel container | production binding choices | resolve through the real container |
Then name a plausible defect. Could the test fail if that defect entered the production code, without first editing the test’s doubles? If the answer is no, the green test is evidence about a smaller contract than its name probably suggests.
A double is valuable when that smaller contract is intentional. When it is accidental, the test is guarding its own call script. Move the boundary until the defect mechanism is back in the room.