11. Extending PHPUnit

Enhancing concrete test cases

You can extend PHPUnit by enhancing concrete test cases with methods that add functionality.

For example, you may use an assertion in a concrete test case to assert that a value created by the system under test matches a regular expression.

Example 11.1 A concrete test case using a default assertion
<?php declare(strict_types=1);
use PHPUnit\Framework\TestCase;

final class OrderIdGeneratorTest extends TestCase
{
    public function testGenerateGeneratesId(): void
    {
        $orderIdGenerator = new OrderIdGenerator;

        $orderId = $orderIdGenerator->generate();

        $this->assertMatchesRegularExpression(
            '/^[a-f0-9]{8}-[a-f0-9]{4}$/',
            sprintf(
                'Failed asserting that "%s" is a valid order ID.',
                $orderId,
            ),
        );
    }
}

You can enhance this concrete test case by extracting a domain-specific assertion.

Example 11.2 A concrete test case using a domain-specific assertion
<?php declare(strict_types=1);
use PHPUnit\Framework\TestCase;

final class OrderIdGeneratorWithDomainSpecificAssertionTest extends TestCase
{
    public function testGenerateGeneratesId(): void
    {
        $orderIdGenerator = new OrderIdGenerator;

        $orderId = $orderIdGenerator->generate();

        $this->assertStringIsOrderId($orderId);
    }

    private function assertStringIsOrderId(string $value): void
    {
        $this->assertMatchesRegularExpression(
            '/^[a-f0-9]{8}-[a-f0-9]{4}$/',
            $value,
            sprintf(
                'Failed asserting that "%s" is a valid order ID.',
                $value,
            ),
        );
    }
}

Extracting abstract test cases

You can extend PHPUnit by extracting abstract test cases to share functionality with other concrete test cases via vertical inheritance.

For example, you may want to pull the domain-specific assertion from above into an abstract test case.

Example 11.3 An abstract test case with a domain-specific assertion
<?php declare(strict_types=1);
use PHPUnit\Framework\TestCase;

abstract class AbstractTestCase extends TestCase
{
    final protected function assertStringIsOrderId(string $value): void
    {
        $this->assertMatchesRegularExpression(
            '/^[a-f0-9]{8}-[a-f0-9]{4}$/',
            $value,
            sprintf(
                'Failed asserting that "%s" is a valid order ID.',
                $value,
            ),
        );
    }
}

You can then enhance a concrete test case by extending the abstract test case.

Example 11.4 A concrete test case extending an abstract test case with a domain-specific assertion
<?php declare(strict_types=1);

final class OrderIdGeneratorExtendingAbstractTestCaseTest extends AbstractTestCase
{
    public function testGenerateGeneratesId(): void
    {
        $orderIdGenerator = new OrderIdGenerator;

        $orderId = $orderIdGenerator->generate();

        $this->assertStringIsOrderId($orderId);
    }
}

Extracting traits

You can extend PHPUnit by extracting traits to share functionality with concrete test cases via horizontal inheritance.

For example, you may want to pull the domain-specific assertion from above into a trait.

Example 11.5 A trait with a domain-specific assertion
<?php declare(strict_types=1);

trait AssertionTrait
{
    final protected function assertStringIsOrderId(string $value): void
    {
        $this->assertMatchesRegularExpression(
            '/^[a-f0-9]{8}-[a-f0-9]{4}$/',
            $value,
            sprintf(
                'Failed asserting that "%s" is a valid order ID.',
                $value,
            ),
        );
    }
}

You can then enhance a concrete test case by using the trait.

Example 11.6 A concrete test case using a trait with a domain-specific assertion
<?php declare(strict_types=1);
use PHPUnit\Framework\TestCase;

final class OrderIdGeneratorUsingAssertionTraitTest extends TestCase
{
    use AssertionTrait;

    public function testGenerateGeneratesId(): void
    {
        $orderIdGenerator = new OrderIdGenerator;

        $orderId = $orderIdGenerator->generate();

        $this->assertStringIsOrderId($orderId);
    }
}

Implementing custom constraints

You can extend PHPUnit by implementing a custom constraint. A custom constraint is a class that extends the PHPUnit\Framework\Constraint\Constraint class.

A custom constraint must implement two methods:

The matches(mixed $other): bool method contains the evaluation logic. It returns true when the constraint is met and false otherwise.

The toString(): string method returns a string representation of the constraint. This string is used in failure messages when an assertion using this constraint fails.

For example, you may want to implement a constraint that checks whether a string is a valid order ID.

Example 11.7 A custom constraint that checks whether a string is a valid order ID
<?php declare(strict_types=1);
use PHPUnit\Framework\Constraint\Constraint;

final class IsValidOrderId extends Constraint
{
    public function toString(): string
    {
        return 'is a valid order ID';
    }

    protected function matches(mixed $other): bool
    {
        return is_string($other)
            && preg_match('/^[a-f0-9]{8}-[a-f0-9]{4}$/', $other) === 1;
    }
}

You can use a custom constraint with assertThat().

Example 11.8 A test using a custom constraint with assertThat()
<?php declare(strict_types=1);
use PHPUnit\Framework\TestCase;

final class IsValidOrderIdTest extends TestCase
{
    public function testGenerateGeneratesId(): void
    {
        $orderIdGenerator = new OrderIdGenerator;

        $orderId = $orderIdGenerator->generate();

        $this->assertThat($orderId, new IsValidOrderId);
    }
}

You can optionally override these methods on the PHPUnit\Framework\Constraint\Constraint class for more control over failure messages:

The failureDescription(mixed $other): string method returns the description of the failure when the constraint is not met. By default, this method combines the string representation of the evaluated value with the string returned by toString().

The additionalFailureDescription(mixed $other): string method can be used to provide additional details, such as a diff, in the failure message.

You can find a list of all built-in constraints in the appendix.

Implementing custom assertions

You can combine a custom constraint with a trait or abstract test case (as shown above) to create a reusable custom assertion.

For example, you can wrap the IsValidOrderId constraint from above in a trait that provides an assertStringIsOrderId() method.

Example 11.9 A trait wrapping a custom constraint into a custom assertion method
<?php declare(strict_types=1);

trait CustomAssertionTrait
{
    final protected static function assertStringIsOrderId(string $value, string $message = ''): void
    {
        static::assertThat($value, new IsValidOrderId, $message);
    }
}

You can then use this trait in a concrete test case.

Example 11.10 A concrete test case using a custom assertion backed by a custom constraint
<?php declare(strict_types=1);
use PHPUnit\Framework\TestCase;

final class OrderIdGeneratorUsingCustomAssertionTraitTest extends TestCase
{
    use CustomAssertionTrait;

    public function testGenerateGeneratesId(): void
    {
        $orderIdGenerator = new OrderIdGenerator;

        $orderId = $orderIdGenerator->generate();

        $this->assertStringIsOrderId($orderId);
    }
}

This approach gives you the best of both worlds: a convenient assertion method for test authors and a well-structured constraint that provides clear failure messages.

Implementing custom comparators

A custom comparator controls how assertEquals() and assertNotEquals() compare objects of a specific type.

A custom comparator is a class that extends the SebastianBergmann\Comparator\Comparator class that must implement two methods:

The accepts(mixed $expected, mixed $actual): bool method returns true when this comparator can handle the given pair of values.

The assertEquals(mixed $expected, mixed $actual, float $delta = 0.0, bool $canonicalize = false, bool $ignoreCase = false): void method performs the comparison. It must throw a SebastianBergmann\Comparator\ComparisonFailure exception when the values are not equal.

For example, consider a Money value object.

Example 11.11 A Money value object
<?php declare(strict_types=1);

final readonly class Money
{
    private int    $amount;
    private string $currency;

    public function __construct(int $amount, string $currency)
    {
        $this->amount   = $amount;
        $this->currency = $currency;
    }

    public function amount(): int
    {
        return $this->amount;
    }

    public function currency(): string
    {
        return $this->currency;
    }
}

You may want to implement a comparator that compares Money objects by their amount and currency.

Example 11.12 A custom comparator for Money objects
<?php declare(strict_types=1);
use SebastianBergmann\Comparator\Comparator;
use SebastianBergmann\Comparator\ComparisonFailure;

final class MoneyComparator extends Comparator
{
    public function accepts(mixed $expected, mixed $actual): bool
    {
        return $expected instanceof Money && $actual instanceof Money;
    }

    public function assertEquals(mixed $expected, mixed $actual, float $delta = 0.0, bool $canonicalize = false, bool $ignoreCase = false): void
    {
        if ($expected->amount() !== $actual->amount() ||
            $expected->currency() !== $actual->currency()) {
            throw new ComparisonFailure(
                $expected,
                $actual,
                $this->exporter()->export($expected),
                $this->exporter()->export($actual),
                'Failed asserting that two Money objects are equal.',
            );
        }
    }
}

You must register a custom comparator with the SebastianBergmann\Comparator\Factory before it can be used. After you are done, you should unregister it again. The best place to do this is in a before-test method such as setUp() and an after-test method such as tearDown() methods of your test case.

Example 11.13 A test registering and using a custom comparator
<?php declare(strict_types=1);
use PHPUnit\Framework\TestCase;
use SebastianBergmann\Comparator\Factory;

final class MoneyComparatorTest extends TestCase
{
    private MoneyComparator $comparator;

    protected function setUp(): void
    {
        $this->comparator = new MoneyComparator;

        Factory::getInstance()->register($this->comparator);
    }

    protected function tearDown(): void
    {
        Factory::getInstance()->unregister($this->comparator);
    }

    public function testMoneyObjectsWithSameAmountAndCurrencyAreEqual(): void
    {
        $this->assertEquals(
            new Money(100, 'EUR'),
            new Money(100, 'EUR'),
        );
    }
}

Once the custom comparator is registered, it will be used whenever assertEquals() or assertNotEquals() is called with two Money objects.

Alternative for simple cases

If your value object has an equals() method (or a similar method), consider using assertObjectEquals() instead of implementing a custom comparator. See assertObjectEquals() for details.

Extending the Test Runner

You can extend PHPUnit by implementing and registering an extension.

Implementing an extension

A PHPUnit extension is a class that implements the PHPUnit\Runner\Extension\Extension interface.

The extension interface declares a bootstrap() method that accepts the PHPUnit configuration, the extension facade, and the extension parameter collection.

Example 11.14 An example extension registering an ExampleSubscriber and an ExampleTracer
<?php declare(strict_types=1);
namespace Vendor\ExampleExtensionForPhpunit;

use PHPUnit\Runner\Extension\Extension;
use PHPUnit\Runner\Extension\Facade;
use PHPUnit\Runner\Extension\ParameterCollection;
use PHPUnit\TextUI\Configuration\Configuration;

final class ExampleExtension implements Extension
{
    public function bootstrap(
        Configuration $configuration,
        Facade $facade,
        ParameterCollection $parameters
    ): void {
        if ($configuration->noOutput()) {
            return;
        }

        $message = 'the-default-message';

        if ($parameters->has('message')) {
            $message = $parameters->get('message');
        }

        $facade->registerSubscriber(new ExampleSubscriber($message));
        $facade->registerTracer(new ExampleTracer);
    }
}

The PHPUnit configuration is an instance of PHPUnit\TextUI\Configuration\Configuration and gives you access to the configuration of PHPUnit after merging configuration options from defaults, the XML configuration file, and command-line options.

You can inspect the configuration object to adjust the behavior of your extension. For example, you may want to extend PHPUnit with an extension that renders output on the console. If that is the case, you may be interested to know whether a user of PHPUnit wants to use colors or prefers a monochrome output.

The parameter collection is an instance of PHPUnit\Runner\Extension\ParameterCollection and gives you access to extension parameters a user has provided via PHPUnit’s XML configuration file. You can use the parameter collection to allow users of the extension to configure the behavior of your extension.

Note

You must verify and process the values from the parameter collection yourself. PHPUnit has no functionality for verifying or casting the values from the parameter collection to other types.

The extension facade is an instance of PHPUnit\Runner\Extension\Facade and allows you to register event subscribers and event tracers using the methods registerSubscribers(), registerSubscriber(), and registerTracer().

The extension facade also provides the following methods for test runner extensions to indicate to the test runner that they intend to replace default functionality or require certain functionality to be activated:

The replacesProgressOutput() method can be used to disable the test runner’s default progress output while it runs the tests.

The replacesResultOutput() method can be used to disable the test runner’s default result output after it finished running the tests.

The replacesOutput() method combines the effects of replacesProgressOutput() and replacesResultOutput() (see above).

The requiresCodeCoverageCollection() method can be used to activate the collection of code coverage information.

The requiresExportOfObjects() method can be used to activate the export of objects for events such as Test\AssertionSucceeded and Test\AssertionFailed, for example.

Implementing an event subscriber

An event subscriber is a class that implements an event subscriber interface.

An event subscriber interface declares a single notify() method that accepts an instance of the corresponding event class.

Example 11.15 An ExampleSubscriber printing a message when PHPUnit emits the ExecutionFinished event
<?php declare(strict_types=1);
namespace Vendor\ExampleExtensionForPhpunit;

use PHPUnit\Event\TestRunner\ExecutionFinished;
use PHPUnit\Event\TestRunner\ExecutionFinishedSubscriber;

final class ExampleSubscriber implements ExecutionFinishedSubscriber
{
    public function __construct(private readonly string $message)
    {
    }

    public function notify(ExecutionFinished $event): void
    {
        print __METHOD__ . PHP_EOL . $this->message . PHP_EOL;
    }
}

After registering an event subscriber with the extension facade, PHPUnit will notify the event subscriber when emitting an event of the corresponding event class.

Note

You can not create an event subscriber that implements more than one event subscriber interface at a time.

If you want to subscribe to more than one event, you need to implement at least one event subscriber for each event you are interested in.

Implementing an event tracer

An event tracer is a class that implements the PHPUnit\Event\Tracer\Tracer interface.

The tracer interface declares a single trace() method that accepts an event.

Example 11.16 An ExampleTracer receiving all events
<?php declare(strict_types=1);
namespace Vendor\ExampleExtensionForPhpunit;

use PHPUnit\Event\Event;
use PHPUnit\Event\Tracer\Tracer;

final class ExampleTracer implements Tracer
{
    public function trace(Event $event): void
    {
        // ...
    }
}

After registering an event tracer with the extension facade, PHPUnit will notify the tracer of every event.

Hint

Are you unsure whether you should implement an event tracer or multiple event subscribers?

If you are interested in every event that PHPUnit emits during the execution of the CLI application, you probably want to implement and register an event tracer.

If you are interested in selected events that PHPUnit emits during the execution of the CLI application, you probably want to implement and register one or more event subscribers.

Understanding events

An event is a class that implements the PHPUnit\Event\Event interface.

The PHPUnit\Event\Event interface declares a telemetryInfo() method that gives you access to telemetry information and an asString() method that returns a string representation of the event.

Each event may implement additional methods that provide access to information available when PHPUnit registers and emits the event.

You can consume, inspect, and process these events in event subscribers or tracers.

You can find a list of all events PHPUnit currently emits in the appendix.

Note

PHPUnit currently does not support registering custom events.

Sharing an extension

You can share a PHPUnit extension as a PHAR or a Composer package.

Sharing an extension as a PHAR

When users of your extension prefer to install PHPUnit as a PHAR, it is best to make your extension also available as a PHAR.

To make your extension loadable as a PHAR, you need to include a PHAR Manifest.

Example 11.17 An example manifest.xml
<?xml version="1.0" encoding="utf-8" ?>
<phar xmlns="https://phar.io/xml/manifest/1.0">
  <contains name="phpunit/phpunit-test-extension" version="1.0.0" type="extension">
    <extension for="phpunit/phpunit" compatible="^10.0"/>
  </contains>

  <requires>
    <php version="^8.1"/>
  </requires>

  <copyright>
    <author name="Sebastian Bergmann" email="sebastian@phpunit.de"/>
    <license type="BSD-3-Clause" url="https://github.com/sebastianbergmann/phpunit/blob/master/LICENSE"/>
  </copyright>
</phar>

Sharing an extension as a Composer package

When users of your extension prefer to install PHPUnit as a Composer package, it is best to make your extension available as a Composer package.

Registering an extension

You can register one or more PHPUnit extensions from PHARs or from Composer package using the extensions, bootstrap, and parameters elements of the PHPUnit XML configuration file.

Registering an extension from a PHAR

When you install PHPUnit as a PHAR, it is best to load extensions from a PHAR.

You can use the extensionsDirectory attribute of the phpunit element to configure the directory from which PHPUnit should load extensions as a PHAR.

Example 11.18 An XML configuration registering an ExampleExtension with parameters, loaded from an extensions directory
<?xml version="1.0" encoding="UTF-8"?>
<phpunit
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/10.0/phpunit.xsd"
    extensionsDirectory="../phpunit-extensions/"
>
    <!-- ... -->

    <extensions>
        <bootstrap class="Vendor\ExampleExtensionForPhpunit\ExampleExtension">
            <parameter name="message" value="the-message"/>
        </bootstrap>
    </extensions>

    <!-- ... -->
</phpunit>

Registering an extension from a Composer package

When you install PHPUnit as a Composer package, it is best to load extensions from Composer packages.

You do not need to configure the extensionsDirectory attribute, as extensions from Composer packages will be available through the autoloading mechanism of Composer.

Example 11.19 An XML configuration registering an ExampleExtension with parameters
<?xml version="1.0" encoding="UTF-8"?>
<phpunit
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/10.0/phpunit.xsd"
>
    <!-- ... -->

    <extensions>
        <bootstrap class="Vendor\ExampleExtensionForPhpunit\ExampleExtension">
            <parameter name="message" value="the-message"/>
        </bootstrap>
    </extensions>

    <!-- ... -->
</phpunit>

Debugging PHPUnit

The test runner’s --log-events-text CLI option can be used to write a plain text representation for each event to a stream. In the example shown below, we use --no-output to disable both the default progress output as well as the default result output. Then we use --log-events-text php://stdout to write event information to standard output:

Example 11.20 Output of “phpunit –no-output –log-events-text php://stdout” command
phpunit --no-output --log-events-text php://stdout
PHPUnit Started (PHPUnit 10.0.0 using PHP 8.2.1 (cli) on Linux)
Test Runner Configured
Test Suite Loaded (2 tests)
Event Facade Sealed
Test Runner Started
Test Suite Sorted
Test Runner Execution Started (2 tests)
Test Suite Started (ExampleTest, 2 tests)
Test Preparation Started (ExampleTest::testOne)
Test Prepared (ExampleTest::testOne)
Assertion Succeeded (Constraint: is true)
Test Passed (ExampleTest::testOne)
Test Finished (ExampleTest::testOne)
Test Preparation Started (ExampleTest::testTwo)
Test Prepared (ExampleTest::testTwo)
Assertion Failed (Constraint: is identical to 'foo', Value: 'bar')
Test Failed (ExampleTest::testTwo)
Failed asserting that two strings are identical.
Test Finished (ExampleTest::testTwo)
Test Suite Finished (ExampleTest, 2 tests)
Test Runner Execution Finished
Test Runner Finished
PHPUnit Finished (Shell Exit Code: 1)

Alternatively, the --log-events-verbose-text CLI option can be used to include information about resource consumption (time since the test runner was started, time since the previous event, and memory usage):

Example 11.21 Output of “phpunit –no-output –log-events-verbose-text php://stdout” command
phpunit --no-output --log-events-verbose-text php://stdout
[00:00:00.000046482 / 00:00:00.000006987] [4194304 bytes] PHPUnit Started (PHPUnit 10.0.0 using PHP 8.2.1 (cli) on Linux)
[00:00:00.048195557 / 00:00:00.048149075] [4194304 bytes] Test Runner Configured
[00:00:00.067646038 / 00:00:00.019450481] [6291456 bytes] Test Suite Loaded (2 tests)
[00:00:00.075942220 / 00:00:00.008296182] [6291456 bytes] Event Facade Sealed
[00:00:00.076452360 / 00:00:00.000510140] [6291456 bytes] Test Runner Started
[00:00:00.084421682 / 00:00:00.007969322] [6291456 bytes] Test Suite Sorted
[00:00:00.084664485 / 00:00:00.000242803] [6291456 bytes] Test Runner Execution Started (2 tests)
[00:00:00.085240320 / 00:00:00.000575835] [6291456 bytes] Test Suite Started (ExampleTest, 2 tests)
[00:00:00.086992385 / 00:00:00.001752065] [6291456 bytes] Test Preparation Started (ExampleTest::testOne)
[00:00:00.087443560 / 00:00:00.000451175] [6291456 bytes] Test Prepared (ExampleTest::testOne)
[00:00:00.088237489 / 00:00:00.000793929] [6291456 bytes] Assertion Succeeded (Constraint: is true)
[00:00:00.089076305 / 00:00:00.000838816] [6291456 bytes] Test Passed (ExampleTest::testOne)
[00:00:00.091027624 / 00:00:00.001951319] [6291456 bytes] Test Finished (ExampleTest::testOne)
[00:00:00.091110095 / 00:00:00.000082471] [6291456 bytes] Test Preparation Started (ExampleTest::testTwo)
[00:00:00.091158739 / 00:00:00.000048644] [6291456 bytes] Test Prepared (ExampleTest::testTwo)
[00:00:00.091991799 / 00:00:00.000833060] [6291456 bytes] Assertion Failed (Constraint: is identical to 'foo', Value: 'bar')
[00:00:00.099242925 / 00:00:00.007251126] [8388608 bytes] Test Failed (ExampleTest::testTwo)
                                                          Failed asserting that two strings are identical.
[00:00:00.099386498 / 00:00:00.000143573] [8388608 bytes] Test Finished (ExampleTest::testTwo)
[00:00:00.099437634 / 00:00:00.000051136] [8388608 bytes] Test Suite Finished (ExampleTest, 2 tests)
[00:00:00.103014760 / 00:00:00.003577126] [8388608 bytes] Test Runner Execution Finished
[00:00:00.103207309 / 00:00:00.000192549] [8388608 bytes] Test Runner Finished
[00:00:00.105879902 / 00:00:00.002672593] [8388608 bytes] PHPUnit Finished (Shell Exit Code: 1)

Wrapping the Test Runner

The PHPUnit\TextUI\Application class is the entry point for PHPUnit’s own CLI test runner. It is not meant to be (re)used by developers who want to wrap PHPUnit to build something such as ParaTest.

For the actual running of tests, PHPUnit\TextUI\Application uses PHPUnit\TextUI\TestRunner::run().

PHPUnit\TextUI\TestRunner::run() requires a PHPUnit\TextUI\Configuration\Configuration, a PHPUnit\Runner\ResultCache\ResultCache, and a PHPUnit\Framework\TestSuite.

A PHPUnit\TextUI\Configuration\Configuration can be built using PHPUnit\TextUI\Configuration\Builder::build(). You need to pass $_SERVER['argv'] to this method. The method then parses CLI arguments/options and loads an XML configuration file, if one can be loaded.

A PHPUnit\Framework\TestSuite can be built from a PHPUnit\TextUI\Configuration\Configuration using PHPUnit\TextUI\Configuration\TestSuiteBuilder::build().

While it is marked @internal, PHPUnit\TextUI\TestRunner is meant to be (re)used by developers who want to wrap PHPUnit’s test runner.