Skip to content
System nominalUTC --:--:--Articles 041
MODULE 05 Lesson 13 / 15 35 MIN

Testing and static analysis

PHPUnit From Zero

Install PHPUnit with Composer, configure test discovery, and learn to read the diff that turns a red test from an accusation into a diagnosis.

You write a Money class, run it by hand once, and it prints something plausible. Three weeks later a customer is charged a tenth of what they owe, and nobody can say which line changed. A test suite is the difference between those two outcomes, and the part that actually pays for itself is not the green bar. It is the diff you get when the bar turns red.

PHPUnit is the test framework nearly every PHP project uses. It is a Composer package, it discovers tests by convention, and it prints a structured comparison when an assertion fails. This lesson builds a small pricing component and a suite around it, shows a passing run and a failing run, and treats the failure output as the deliverable rather than an inconvenience.

A test is a claim that runs itself

The mental model that makes the rest of this lesson obvious is that a test is a claim about behaviour, written so a machine can check it. Money::fromDecimal(12.50, 'EUR') plus Money::fromDecimal(7.25, 'EUR') has 1975 minor units, not 19.75 euros and not a float. That claim is either true or false, and a test states it in a form that fails loudly when it stops being true.

Two properties follow. A test that never fails has never been shown to test anything, so the failing run matters as much as the passing one. And a test that asserts something vacuous, such as that a method returns an integer, will stay green while the arithmetic underneath is wrong. The next lesson is largely about that second failure mode.

Everything below was run against PHPUnit 13.4.3 on PHP 8.5.6. The output blocks are copied from the terminal, not reconstructed.

Composer installs the tool, not your project's users

PHPUnit belongs in require-dev, because it runs on a developer machine and in CI and never in production. In a project whose composer.json already declares PSR-4 autoloading, adding it is one command:

composer require --dev phpunit/phpunit

That writes an entry into composer.json, resolves a version against the PHP you are running, and installs the binary at vendor/bin/phpunit:

$ ./vendor/bin/phpunit --version
PHPUnit 13.4.3 by Sebastian Bergmann and contributors.

Composer also writes a scripts block if you ask it to, and a project that has to be run through the full path is a project where people forget to run the tests. Add two scripts so the common commands are short:

{
    "scripts": {
        "test": "phpunit",
        "test:filter": "phpunit --filter"
    }
}

From that point composer test runs the suite with the project's own configuration, and the path to the binary never appears in documentation or in a CI file again.

The phpunit.xml that matters is short

PHPUnit finds phpunit.xml in the working directory automatically. The file only needs to answer four questions: how do I bootstrap the autoloader, where are the tests, which source files am I measuring, and how strict should the run be.

<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
         bootstrap="vendor/autoload.php"
         colors="true"
         cacheDirectory=".phpunit.cache"
         displayDetailsOnTestsThatTriggerWarnings="true"
         failOnWarning="true"
         failOnDeprecation="true">
    <testsuites>
        <testsuite name="unit">
            <directory>tests</directory>
        </testsuite>
    </testsuites>
    <source>
        <include>
            <directory>src</directory>
        </include>
    </source>
</phpunit>

bootstrap is the only element most projects must get right: without the Composer autoloader, PHPUnit discovers your test classes and then dies on the first class they reference. cacheDirectory is where PHPUnit keeps its result cache, which is what makes a second run faster. The source element is what coverage is measured against, so it is the reason you list src and not tests.

The failOn attributes are the ones people leave out and later regret. A deprecation warning that does not fail the build is a deprecation warning nobody reads until the upgrade that removes the method. Turning it into a failure on the day it appears is cheaper than turning it into an outage later.

Discovery is a convention: PHPUnit looks for files whose name ends in Test.php under the configured directory, and inside them for public methods whose name begins with test. The class name and file name do not have to match for the framework, but PSR-4 autoloading requires it, which is why both hold in practice.

One testsuite is enough to start

A second suite for integration tests that need a database is worth adding when those tests exist. Splitting a suite that only has unit tests into unit and feature directories produces two commands that run the same thing.

Your first test class and a real passing run

Here is the class under test. Money is kept in minor units as an integer, because floating-point arithmetic on currency loses cents in ways that are hard to notice and easy to prove:

final class Money
{
    private function __construct(
        public readonly int $minorUnits,
        public readonly string $currency,
    ) {
    }

    public static function fromMinorUnits(int $minorUnits, string $currency): self
    {
        if (!preg_match('/^[A-Z]{3}$/', $currency)) {
            throw new InvalidArgumentException(sprintf('Not an ISO 4217 code: %s', $currency));
        }

        return new self($minorUnits, $currency);
    }

    public static function fromDecimal(float $amount, string $currency): self
    {
        return self::fromMinorUnits((int) round($amount * 100), $currency);
    }

    public function plus(self $other): self
    {
        $this->assertSameCurrency($other);

        return new self($this->minorUnits + $other->minorUnits, $this->currency);
    }

    public function format(): string
    {
        return sprintf('%s %.2f', $this->currency, $this->minorUnits / 100);
    }

    private function assertSameCurrency(self $other): void
    {
        if ($this->currency !== $other->currency) {
            throw new InvalidArgumentException(
                sprintf('Cannot combine %s with %s', $this->currency, $other->currency)
            );
        }
    }
}

A test class extends PHPUnit\Framework\TestCase and lives in a namespace that PSR-4 maps to tests/. The assertions are static calls, which is the style PHPUnit's own documentation uses and the one that keeps the intent at the start of the line:

use PHPUnit\Framework\TestCase;

final class MoneyTest extends TestCase
{
    public function testTwoAmountsInTheSameCurrencyAdd(): void
    {
        $total = Money::fromDecimal(12.50, 'EUR')->plus(Money::fromDecimal(7.25, 'EUR'));

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

    public function testAddingDifferentCurrenciesIsRejected(): void
    {
        $this->expectException(InvalidArgumentException::class);
        $this->expectExceptionMessage('Cannot combine EUR with USD');

        Money::fromDecimal(1.00, 'EUR')->plus(Money::fromDecimal(1.00, 'USD'));
    }
}

The run is unremarkable, which is the point:

$ ./vendor/bin/phpunit
PHPUnit 13.4.3 by Sebastian Bergmann and contributors.

Runtime:       PHP 8.5.6
Configuration: /srv/app/phpunit.xml

...........                                                       11 / 11 (100%)

Time: 00:00.002, Memory: 20.00 MB

OK (11 tests, 16 assertions)

Eleven dots is one character per test. When you are looking at a run of two hundred tests, the character that matters is the one that is not a dot.

Choose the assertion that can actually fail

assertSame compares type and value with ===. assertEquals compares with ==-like semantics and is happy when '1975' equals 1975. That difference is the whole reason to prefer assertSame for anything you control, and it costs a moment to appreciate why: a function that returns a numeric string from a database driver will pass an assertEquals and fail the first time it is used in an arithmetic expression that expects a number.

PHPUnit 13 ships 176 static assert* helpers. The ones that earn their keep in ordinary code are the ones that name a property rather than a value: assertCount, assertInstanceOf, assertStringStartsWith, assertArrayHasKey, assertMatchesRegularExpression, assertJsonStringEqualsJsonString, assertNull, assertIsInt and assertGreaterThan. Each produces a failure message that describes what was expected in the vocabulary of the domain, and that message is what you read at 2am.

When you need several assertions about one object, write them in one test method rather than five. PHPUnit stops the test at the first failure, so five methods assert five separate claims; one method with five assertions asserts one claim examined five ways. The distinction matters for how the report reads when it goes red.

A provider turns one test into a table

Currency formatting has more cases than you will write method names for. A data provider is a public static method returning an array whose keys become part of the test name and whose values become the arguments:

use PHPUnit\Framework\Attributes\DataProvider;

#[DataProvider('formatProvider')]
public function testFormatting(int $minorUnits, string $expected): void
{
    self::assertSame($expected, Money::fromMinorUnits($minorUnits, 'EUR')->format());
}

/**
 * @return array<string, array{int, string}>
 */
public static function formatProvider(): array
{
    return [
        'zero' => [0, 'EUR 0.00'],
        'whole euros' => [1900, 'EUR 19.00'],
        'cents' => [1999, 'EUR 19.99'],
        'single cent' => [1, 'EUR 0.01'],
        'large total' => [123456789, 'EUR 1234567.89'],
    ];
}

The array key is not decoration. Each row runs as its own test and appears in the report by name, so testFormatting@single cent fails on its own without hiding the other four rows.

The attribute form is required. The @dataProvider docblock annotation that older tutorials show is not deprecated in PHPUnit 13, it is ignored, and the test then fails with an ArgumentCountError because it was called with no arguments. A test suite upgraded from an earlier major version is where this surfaces, and the error message points at the test rather than at the metadata, which sends people looking in the wrong place.

Providers are also where a table earns its place as documentation. A reader who wants to know how the formatter treats a single cent reads the provider, not the class.

Every test starts from the same state, or your tests are coupled

setUp runs before every test method and tearDown after it, which means a test method cannot see anything a previous test left behind. This is not a convenience, it is the property that makes failures reproducible and lets tests run in any order:

final class LedgerTest extends TestCase
{
    /** @var list<string> */
    private array $entries;
    private string $path;

    protected function setUp(): void
    {
        $this->entries = [];
        $this->path = tempnam(sys_get_temp_dir(), 'ledger');
    }

    protected function tearDown(): void
    {
        unlink($this->path);
    }
}

Two test methods in that class both assert assertCount(0, $this->entries) and both pass, because the fixture is rebuilt. Move the initialisation to a setUpBeforeClass method or a typed property default and the second test starts seeing the first test's leftovers, which is the class of bug that produces a suite that passes alone and fails together.

tearDown is where cleanup goes, and it runs even when the test failed. A test that creates a temporary file and does not remove it turns a hundred runs into a hundred files, and a test that starts a transaction and does not roll it back corrupts the next test in a way that looks like a bug in the code under test.

Run the tests you are working on

Running the whole suite after each edit is the habit that makes people stop running tests. PHPUnit gives you three ways to narrow the run, and they compose:

# one method, by regular expression
./vendor/bin/phpunit --filter testFormatting@single

# one class
./vendor/bin/phpunit --filter MoneyTest

# one configured testsuite
./vendor/bin/phpunit --testsuite unit

--filter matches against the test name, so it also selects every row of a provider whose name contains the string. The subset run reports on the subset only; the count changes from 11 / 11 to 1 / 1, which is how you know the filter did what you meant rather than quietly matching nothing. A filter that matches nothing prints No tests executed! and exits successfully, so it will not fail your script. Read the count.

Lab 30 minutes

Write a Cart class that stores quantities by SKU and can report the total item count and the quantity for one SKU. Then write a test class with:

A passing test for accumulation, a test that asserts adding a second currency's price is rejected, a data provider with at least four rows for a lineCount() method, and a setUp that gives each test a fresh cart.

Then break the class on purpose. Change one arithmetic operator, run the suite, and read the diff before you revert it. Write down whether the diff told you which line was wrong or only that a value differed.

Show the solution and the reasoning

The reasoning worth internalising is why each assertion was chosen, not the class itself. A test that says assertSame(5, $cart->totalItems()) can only fail when the total is wrong. A test that says assertGreaterThan(0, $cart->totalItems()) fails when the cart is broken and also when it is empty, which is a different bug, and it passes for every wrong total above zero.

final class CartTest extends TestCase
{
    private Cart $cart;

    protected function setUp(): void
    {
        $this->cart = new Cart();
    }

    public function testQuantitiesAccumulate(): void
    {
        $this->cart->add('SKU-1', 2);
        $this->cart->add('SKU-1', 3);

        self::assertSame(5, $this->cart->quantityOf('SKU-1'));
        self::assertSame(5, $this->cart->totalItems());
    }

    public function testAnUnknownSkuIsZeroRatherThanAnError(): void
    {
        self::assertSame(0, $this->cart->quantityOf('SKU-404'));
    }

    #[DataProvider('countProvider')]
    public function testLineCount(array $lines, int $expected): void
    {
        foreach ($lines as $sku => $quantity) {
            $this->cart->add($sku, $quantity);
        }

        self::assertSame($expected, $this->cart->lineCount());
    }

    public static function countProvider(): array
    {
        return [
            'empty' => [[], 0],
            'one line' => [['SKU-1' => 1], 1],
            'two lines' => [['SKU-1' => 1, 'SKU-2' => 4], 2],
            'same sku twice' => [['SKU-1' => 1], 1],
        ];
    }
}

The fourth provider row is the interesting one. Adding the same SKU twice must produce one line, not two, and a straightforward implementation that appends to an array of lines passes the first three rows and fails that one. That row is the reason the provider exists.

On the deliberate break: replacing + with * in add produces a diff that names both values, and the expected value is the one you wrote in the test. The diff does not tell you which line is wrong, and it should not be expected to. It tells you that the observable behaviour changed, and the line number in the test file points at the claim that broke. Narrowing from there is a five-second job once the suite exists and a five-hour job when it does not.

One detail worth noticing in the failure output above: each provider row reports as a separate numbered failure with its own data shown in parentheses. That is why the provider keys are worth writing. testFormatting@single cent with data (1, 'EUR 0.01') identifies the case without anyone counting rows.

Progress

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