levelmax World 4 Testing Properly
  1. 1
  2. 2
  3. 3
  4. 4
  5. 5
  6. B
L
Level 4.3 ~14 min Target: PHPStan max

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 mockexpects skus() once, then level() three times
The refactorcalls levels() once
Same restock list. Red build.

Power-up

Before
<?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));
    }
}
After
<?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));
    }
}

Quick check

1. A refactor changes which Inventory methods RestockList calls, but not the list it returns. Which test still passes?
2. When is a mock the right tool?
3. Your fake Inventory filters out sold-out items before returning levels. What's wrong with that?

CHALLENGE

RestockList (read only) used to ask Inventory for every SKU, then for each SKU's level. It was refactored to read all the levels in one call. The restock list is exactly the same, but the test broke, because the mock was scripted for the old calls.

In tests/RestockListTest.php, replace the mock with a fake: a small class that implements Inventory by keeping the levels in an array. Then test what RestockList returns, until your tests catch every planted bug.

REFERENCE
final class InMemoryInventory implements Inventory
{
    /** @param array<non-empty-string, int<0, max>> $levels */
    public function __construct(private readonly array $levels) {}

    // skus(), level() and levels(), each answered from $this->levels
}

A test file can declare the fake above the test class. PHPUnit runs the class named after the file.

Running on PHP 8.3…

Run the tests, run PHPStan, or submit for both. Ctrl/⌘ + Enter submits from the editor.