Index

A Mock Fortress Tests a Call Script

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 collaborator

The 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 4

The 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:

TargetWhat replacing it removesDecision in this test
HTTP catalogueremote availability and payloadkeep a fixed port implementation
discount policyowned price calculationrun the real policy
tax policyowned tax calculationrun the real policy
shipping policyowned shipping calculationrun the real policy
quote serializerowned output mappingrun the real serializer
Laravel containerproduction binding choicesresolve 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.