Fakes over mocks
RestockList used to ask the inventory for every SKU, then for each SKU's stock level. Someone refactored it to read every level in one call. The list it returns didn't change at all. The test went red anyway, because the mock was a script of the old calls: skus() once, then level() three times.
A test that breaks when behaviour stays the same is worse than no test. It teaches people to update tests until they pass, and then they stop believing them.
Mocks, stubs and fakes
| What it is | What the test checks | |
|---|---|---|
| Mock | A scripted object that expects certain calls: expects($this->once())->method('skus') |
That the code made those calls |
| Stub | A scripted object that returns canned answers: willReturn([...]) |
Whatever you assert, but the script still assumes which methods get called |
| Fake | A small, working implementation, like an inventory kept in an array | What the code returns or changes |
A mock pins down how the code works. A fake lets you check what it does. The refactor changed how and left what alone, so a test with a fake keeps passing.
final class InMemoryInventory implements Inventory { /** @param array<non-empty-string, int<0, max>> $levels */ public function __construct(private readonly array $levels) {} public function levels(): array { return $this->levels; } // ...the other Inventory methods, answered from the same array }
A fake is ordinary code, so PHPStan checks it at max. If Inventory gains a method, PHPStan reports every fake that lacks it, where a mock would quietly return a default.
When a mock is the right tool
A mock earns its place when the call itself is the behaviour. "We charged the card exactly once" is something to check, and a payment gateway is something you don't want a test to call for real. Even then, a fake that records what it was asked to do ($gateway->charges) often reads better than an expectation.
| The mock | expects skus() once, then level() three times |
|---|---|
| The refactor | calls levels() once |
Power-up
<?php use PHPUnit\Framework\TestCase; final class RestockListTest extends TestCase { public function testItListsTheSkusRunningLow(): void { $inventory = $this->createMock(Inventory::class); $inventory->expects($this->once())->method('skus')->willReturn(['MUG-BLUE', 'MUG-RED']); $inventory->expects($this->exactly(2))->method('level')->willReturnMap([ ['MUG-BLUE', 12], ['MUG-RED', 2], ]); self::assertSame(['MUG-RED'], RestockList::from($inventory, 5)); } }
<?php use PHPUnit\Framework\TestCase; final class RestockListTest extends TestCase { public function testItListsTheSkusRunningLow(): void { $inventory = new InMemoryInventory(['MUG-BLUE' => 12, 'MUG-RED' => 2]); self::assertSame(['MUG-RED'], RestockList::from($inventory, 5)); } }