Skip to content
System nominalUTC --:--:--Articles 041
MODULE 05 Lesson 14 / 15 40 MIN

Testing and static analysis

Test Doubles and Testing What Matters

Stubs, mocks and fakes, why a green test can hide a real bug, and how the humble object pattern turns untestable code into a function you can pin down.

A test suite that passes on every run is not evidence that the code is correct. It is evidence that the code has not changed in a way the suite can see, and those are different claims. The gap between them widens in proportion to how much of the system your tests replaced with doubles.

A test double is a stand-in for a dependency you do not want to run: a payment gateway, a clock, a database. Doubles make tests fast and deterministic, which is why every suite has them. They also let you assert that a method was called, and that assertion is where suites quietly stop testing anything. This lesson builds both kinds of test, shows a green test sitting on top of a live bug, and then looks at the design change that removes the need for the double.

A double replaces a dependency, not a behaviour

The reason to reach for a double is that the real dependency cannot be used in the test. It is slow, it needs credentials, it returns a different value each time, or it charges a credit card. Replacing it gives the test a control point over what flows into the code under test, and that control is what makes an outcome predictable.

Mesyaros' definition, quoted in PHPUnit's own documentation, says a double "merely has to provide the same API as the real one so that the SUT thinks it is the real one." A double that implements the interface can stand in. A double that returns something the real implementation could never return is testing a program that does not exist. So the first question to ask about any double is what the real thing would have returned here. If the answer is not obvious, the test is checking your fiction rather than the code.

Stubs control inputs, mocks assert outputs

PHPUnit's documentation draws the line clearly. A test stub gives you control over the indirect inputs of the system under test. A mock object is a stub that can additionally be given expectations about how it will be called, which makes it an observation point for the indirect outputs.

In code, that is the difference between createStub and createMock. A stub answers questions:

$rates = self::createStub(RateProvider::class);
$rates->method('rate')->willReturn(0.92);

A mock is asked to watch instead:

$rates = self::createMock(RateProvider::class);
$rates->expects(self::never())->method('rate');

The second block asserts that a specific call did not happen. Used sparingly that is a real claim about the code. Used as a default habit, it is the failure mode this lesson is about.

What changed in PHPUnit 13

Test doubles created by createStub() no longer have an expects() method at all. Calling it is a fatal Error: Call to undefined method ...::expects(), not a deprecation. Before PHPUnit 12.5.11 the call was accepted and the with() constraint was silently ignored, so a stub could be configured to expect arguments and never check them. The removal is why the two builders cannot be swapped by mistake any more.

PHPUnit also warns you in the other direction. Creating a mock and never configuring an expectation on it produces a PHPUnit notice, and the message names the fix directly:

1 test triggered 1 PHPUnit notice:

1) Shop\Checkout\Tests\DoubleBehaviourTest::testAMockWithNoExpectations
No expectations were configured for the mock object for Shop\Checkout\RateProvider.
Consider refactoring your test code to use a test stub instead.
The #[AllowMockObjectsWithoutExpectations] attribute can be used to opt out of this check.

OK, but there were issues!
Tests: 1, Assertions: 1, PHPUnit Notices: 1.

A notice that does not fail the build is a notice nobody acts on, so add failOnPhpunitNotice="true" to phpunit.xml once the existing notices are dealt with. The note is the framework telling you that you built a spy and never used it as one.

Asserting a call happened proves the call happened

This is the sentence worth reading twice. expects(self::once())->method('save') asserts that save was invoked exactly once. It says nothing about whether the order was stored, whether the right amount was stored, or whether the code then returned a sensible value. If the method is called once with the wrong data, the assertion passes.

There is a second, subtler version of the problem. A mock encodes your current understanding of which calls the code should make. Refactor the code to make fewer calls and the test fails even though the behaviour improved, so the fastest way to make it pass is to call the dependency again. A suite that is mostly mock expectations couples every test to the implementation and turns refactoring into a negotiation with your own tests.

Mocks earn their place when the call is the observable behaviour. Charging a card is the behaviour; the fact that charge() was called is the outcome that matters. Saving a row is not like that. What matters is what is in the row afterwards, and a fake plus an assertion about the stored state tests that directly.

A test that passes against a real bug

Here is an OrderTotal that applies a gift card to an order. Read the conditional in the middle:

public function calculate(Price $items, ?GiftCard $giftCard, RateProvider $rates): Price
{
    if ($giftCard === null) {
        return $items;
    }

    $payable = $giftCard->balance->currency === $items->currency
        ? $giftCard->balance->minorUnits
        : $items->minorUnits;

    return Price::of(max(0, $items->minorUnits - $payable), $items->currency);
}

The test that was written for it is entirely reasonable, and it passes:

public function testAGiftCardReducesTheTotal(): void
{
    $total = (new OrderTotal())->calculate(
        Price::of(5000, 'EUR'),
        new GiftCard('GC-1', Price::of(1000, 'EUR')),
        new FixedRates([]),
    );

    self::assertSame(4000, $total->minorUnits);
    self::assertSame('EUR', $total->currency);
}

Now run the same code with a gift card in a different currency:

order:         EUR 50.00
gift card:     USD 10.00
charged:       EUR 0.00
should be:     EUR 40.80

The order is free. The branch the happy-path test never reaches uses the order's own total as the amount payable, so any card denominated in another currency covers the whole order. The original test passes because it only constructs the case where the two currencies match, which is the branch that happens to be correct.

This is the failure mode to watch for: the test is green because it cannot reach the code that is wrong. Coverage tools will report the line as covered if some other test touches it, which is why coverage percentage is a weak signal. The fix is not more assertions on the same example. It is a second example that crosses the branch:

public function testAGiftCardInAnotherCurrencyIsConvertedFirst(): void
{
    $total = (new OrderTotal())->calculate(
        Price::of(5000, 'EUR'),
        new GiftCard('GC-1', Price::of(1000, 'USD')),
        new FixedRates(['USDEUR' => 0.92]),
    );

    self::assertSame(4080, $total->minorUnits);
}

With that test in place the suite fails, and the correct implementation is the one that converts the balance through the rate provider before subtracting it. The lesson from the sequence is not "write more tests". It is "write the second example before you believe the first one".

Write a fake when you want behaviour, a mock when you want a signal

PHPUnit's documentation is explicit that dummies, fakes and spies are outside its scope, which is not the same as saying they are less useful. A fake is a working implementation with a simpler mechanism: an in-memory repository, a dictionary of exchange rates, a clock you control.

final class FixedRates implements RateProvider
{
    /** @param array<string, float> $table */
    public function __construct(private readonly array $table)
    {
    }

    public function rate(string $from, string $to): float
    {
        return $this->table[$from . $to]
            ?? throw new UnknownCurrency(sprintf('No rate for %s to %s', $from, $to));
    }
}

Twenty lines, no framework, and it works for any number of tests with different currencies because the data is a constructor argument. A mock configured row by row to return the same values would be longer, would not throw a domain exception for a missing rate, and would not tell a reader what a rate provider is supposed to do. A fake also fails the way the real thing fails, which makes it possible to test the error path without stubbing an exception.

The rule that falls out: use a fake when the dependency has behaviour you can reimplement honestly, use a stub when you only need one controlled value, and use a mock when the call itself is the fact you are trying to establish.

Notice that all of these depend on RateProvider and none of them name a concrete class. That is what makes the exchange-rate source swappable, and it is also why final classes stop being a problem: PHPUnit can double a final class only when it can generate a subclass, so the classes that resist doubling are the ones that should have been interfaces. When a class cannot be constructed in a test without a container, the usual cause is a constructor that takes a concrete class holding a database connection. Extracting the interface improves the production code as well as the test.

The humble object pattern isolates the part that cannot be tested

Some code is genuinely hard to test because it is all boundary: it reads a socket, parses bytes, and writes a response. Unit testing it means simulating the socket, and the simulation becomes more complicated than the parsing.

The humble object pattern splits it. The hard part keeps the I/O and does almost no thinking. The thinking moves into a plain function that takes strings and returns objects, with no framework, no socket and no static state:

final readonly class RequestLine
{
    private function __construct(
        public string $method,
        public string $path,
        public string $version,
    ) {
    }

    public static function parse(string $line): self
    {
        $parts = preg_split('/\s+/', trim($line));

        if ($parts === false || count($parts) !== 3) {
            throw new InvalidArgumentException(sprintf('Malformed request line: %s', $line));
        }

        [$method, $path, $version] = $parts;

        if (!in_array($method, ['GET', 'POST', 'PUT', 'DELETE'], true)) {
            throw new InvalidArgumentException(sprintf('Unsupported method: %s', $method));
        }

        return new self($method, $path, $version);
    }
}

That class is testable with a string and a data provider, and the cases that matter are the ugly ones: an empty line, a line with the HTTP version missing, a method you do not support, and a line that arrived with a carriage return still attached because the request came over a real socket. The framing code that calls parse is left with nothing but I/O, and code that only does I/O does not need a unit test.

The pattern is worth having in your vocabulary because it is usually the answer to "this class is untestable". The response is not a bigger mock. It is to move the decision out of the class.

Test runtime is a design signal

Two tests of the same behaviour, one against a fake and one against a stub that simulates a round trip, ran on the same machine at the same time. The fake finished in 0.3 milliseconds. The simulated network call took 511.3 milliseconds for 201 invocations, which is roughly 2.5 milliseconds each.

testWithAFake                            0.3 ms
testWithANetworkBoundStub              511.3 ms

Scale that to a real rate provider with a 40 ms round trip and a test that exercises a hundred prices. The suite stops being something you run while editing and becomes something you run before pushing, and from there it becomes something you run in CI and skip locally. The runtime of a test measures how much of the world it touches, and the fix for a slow unit test is a design change rather than a faster machine. The signal is the shape rather than the absolute number: a handful of tests taking hundreds of milliseconds each usually means the code under test is calling out to something, and the double is papering over a dependency that did not need to be there.

Lab 35 minutes

You inherit a class that validates a webhook signature. Its constructor takes an HTTP client, and isValid() fetches the public key from a URL, extracts the signature from a header string, and compares a hash.

Write two tests. The first uses a stub for the HTTP client so it passes. Then answer, in writing: name one way the production code could be wrong that your first test would not notice.

Then refactor. Extract the part that decides whether a signature is valid into a pure function or value object, give the class a test that crosses at least one error branch, and record how long the suite takes before and after.

Show the solution and the reasoning

The concrete bug that a happy-path stub test cannot see is a signature comparison written with == instead of hash_equals(). Both return true for a correct signature, so the stub test passes. Only the timing-safe comparison resists a timing attack, and no assertion about the return value can distinguish them. The honest test for that property is not a unit test at all.

The second blind spot is more common: a stub that returns a fixed public key means the test never exercises the failure path for a key the server refuses to serve, and it never sees what happens when the fetched key is malformed. A stub is a control point; a stub that only ever succeeds leaves the other branches unvisited.

final readonly class Signature
{
    public function __construct(
        private string $algorithm,
        private string $hexDigest,
    ) {
    }

    public static function fromHeader(string $header, string $algorithm): self
    {
        $prefix = $algorithm . '=';

        if (!str_starts_with($header, $prefix)) {
            throw new InvalidArgumentException(sprintf('Unsupported signature: %s', $header));
        }

        $digest = substr($header, strlen($prefix));

        if (!preg_match('/^[0-9a-f]{64}$/', $digest)) {
            throw new InvalidArgumentException('Malformed digest');
        }

        return new self($algorithm, $digest);
    }

    public function matches(string $body, string $secret): bool
    {
        return hash_equals($this->hexDigest, hash_hmac($this->algorithm, $body, $secret));
    }
}

Now the decisions are testable with three strings. The HTTP client is out of the picture, so a rejected algorithm is a one-line provider row rather than a stubbed 403, and the comparison uses hash_equals, which is the point the refactor makes visible: the property this code needs is constant-time comparison, and putting the comparison in a named method is what lets a reviewer see it.

On timing, the honest answer for most projects is that the refactored suite is under a millisecond and the stubbed one costs whatever the stub costs. The direction matters more than the number. If testing string parsing needs a stub for an HTTP client, the parsing and the fetching live in the same method.

The graded part of this lab is the sentence you wrote about what the first test cannot see. A suite whose authors can each name its blind spots is worth more than one that is merely large.

Progress

Mark this lesson done and it stays marked in this browser.