Extending PHPUnit with its new event system

You can extend phpunit/phpunit by creating and using abstract test classes and traits - or by using the event system of phpunit/phpunit.

As one of the contributors to the new event system of phpunit/phpunit, I will give you an overview of the iterations of the event systems of phpunit/phpunit, show how you can migrate from any of the previous event systems to PHPUnit's new event system, and share insights on components and the events in the new event system of PHPUnit.

tl;dr

Refer to the official phpunit/phpunit documentation for learning how to extend PHPUnit.

Inspect ergebnis/phpunit-slow-test-detector, an extension for detecting slow tests in PHPUnit.

This extension, inspired by johnkary/phpunit-speedtrap, uses the new event system of phpunit/phpunit. I started working on this package on January 23, 2021, so it is probably the first extension using the new event system of phpunit/phpunit. It provided helpful feedback during the development of the new event system of phpunit/phpunit.

Iterations of the event system

So far, the event system of phpunit/phpunit has seen three iterations:

Test listener event system

The first iteration of the event system of phpunit/phpunit, the test listener event system, has been available since the release of phpunit/phpunit:0.3 in 2002.

The test listener event system has been deprecated since the release of phpunit/phpunit:8.0.0 on February 1, 2019, and it has been removed with the release of phpunit/phpunit:10.0.0 on February 3, 2023.

Events in the test listener event system

The test listener event system in phpunit/phpunit:9.6.3 ships with a single TestListener interface that defines nine methods.

<?php

declare(strict_types=1);

namespace PHPUnit\Framework;

interface TestListener
{
    public function addError(
        Test $test,
        \Throwable $t,
        float $time
    ): void;

    public function addWarning(
        Test $test,
        Warning $e,
        float $time
    ): void;

    public function addFailure(
        Test $test,
        AssertionFailedError $e,
        float $time
    ): void;

    public function addIncompleteTest(
        Test $test,
        \Throwable $t,
        float $time
    ): void;

    public function addRiskyTest(
        Test $test,
        \Throwable $t,
        float $time
    ): void;

    public function addSkippedTest(
        Test $test,
        \Throwable $t,
        float $time
    ): void;

    public function startTestSuite(TestSuite $suite): void;

    public function endTestSuite(TestSuite $suite): void;

    public function startTest(Test $test): void;

    public function endTest(
        Test $test,
        float $time
    ): void;
}

Each of the nine methods in the TestListener interface corresponds to an event during a test run. When you have registered the test listener, phpunit/phpunit will invoke these methods as it sees fit.

Implementing an extension using the test listener event system

To implement an extension using the test listener event system, you must create a class that implements the TestListener interface.

Here is an example of an implementation of the TestListener interface from johnkary/phpunit-speedtrap:4.0.1, a popular extension that detects and reports slow tests - and can even fail a test run.

<?php

declare(strict_types=1);

namespace JohnKary\PHPUnit\Listener;

use PHPUnit\Framework;

class SpeedTrapListener implements Framework\TestListener
{
    use Framework\TestListenerDefaultImplementation;

    // ...

    public function endTest(
        Framework\Test $test,
        float $time
    ): void
    {
        // ...
    }

    public function startTestSuite(Framework\TestSuite $suite): void
    {
        // ...
    }

    public function endTestSuite(Framework\TestSuite $suite): void
    {
        // ...
    }

    // ...
}

This test listener uses the TestListenerDefaultImplementation trait.

The trait conveniently provides stubs for methods that the TestListener interface defines. If you import the trait into your test listener, you can focus on implementing methods for events that concern you - and ignore the rest.

<?php

declare(strict_types=1);

namespace PHPUnit\Framework;

trait TestListenerDefaultImplementation
{
    public function addError(
        Test $test,
        \Throwable $t,
        float $time
    ): void {
    }

    public function addWarning(
        Test $test,
        Warning $e,
        float $time
    ): void {
    }

    public function addFailure(
        Test $test,
        AssertionFailedError $e,
        float $time
    ): void {
    }

    public function addIncompleteTest(
        Test $test,
        \Throwable $t,
        float $time
    ): void {
    }

    public function addRiskyTest(
        Test $test,
        \Throwable $t,
        float $time
    ): void {
    }

    public function addSkippedTest(
        Test $test,
        \Throwable $t,
        float $time
    ): void {
    }

    public function startTestSuite(TestSuite $suite): void
    {
    }

    public function endTestSuite(TestSuite $suite): void
    {
    }

    public function startTest(Test $test): void
    {
    }

    public function endTest(
        Test $test,
        float $time
    ): void {
    }
}

To allow the configuration of a test listener, you need to add a constructor to your test listener:

<?php

declare(strict_types=1);

namespace JohnKary\PHPUnit\Listener;

use PHPUnit\Framework;

class SpeedTrapListener implements TestListener
{
    use Framework\TestListenerDefaultImplementation;

    // ...

    public function __construct(array $options = [])
    {
        // ...
    }

    // ...
}

Registering an extension using the test listener event system

To register an extension using the test listener event system, you need to configure it using the <listeners> and <listener> elements of phpunit.xml.

The following configuration registers johnkary/phpunit-speedtrap:4.0.1 with default options:

<phpunit
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
    bootstrap="vendor/autoload.php"
>
    <listeners>
        <listener class="JohnKary\PHPUnit\Listener\SpeedTrapListener" />
    </listeners>
</phpunit>

The following configuration registers johnkary/phpunit-speedtrap:4.0.1 with custom options:

<phpunit
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
    bootstrap="vendor/autoload.php"
>
    <listeners>
        <listener class="JohnKary\PHPUnit\Listener\SpeedTrapListener">
            <arguments>
                <array>
                    <element key="slowThreshold">
                        <integer>250</integer>
                    </element>
                    <element key="stopOnSlow">
                        <boolean>true</boolean>
                    </element>
                </array>
            </arguments>
        </listener>
    </listeners>
</phpunit>

phpunit/phpunit provides facilities for casting values from the <arguments> element. phpunit/phpunit will pass these values to the constructor of the test listener.

In the example above, phpunit/phpunit will pass the following value to the constructor of SpeedTrapListener:

[
    'slowThreshold' => 250,
    'stopOnSlow'    => true,
]

Disadvantages of the test listener event system

As you have seen above, the test listener system has a few disadvantages.

To implement a test listener, you need to create a class that implements all methods of the TestListener interface, even when you are only interested in a single or a subset of all available events. This requirement violates the interface segregation principle.

The methods of the test listener accept mutable implementations of Test and mutable instances of TestSuite. Passing around mutable objects has an undesirable effect: a test listener can modify the outcome of test runs by manipulating these mutable objects.

Examples of test listener implementations that modify the outcome of test runs include:

Deprecation and removal of the test listener event system

The test listener event system has been deprecated since the release of phpunit/phpunit:8.0.0 on February 1, 2019. It has been removed with the release of phpunit/phpunit:10.0.0 on February 3, 2023.

Hooks event system

The second iteration of the event system of phpunit/phpunit, the hooks event system, has been available since the release of phpunit/phpunit:7.1.0 on April 6, 2018.

The hooks event system has been deprecated since the release of phpunit/phpunit:8.5.23 on February 1, 2022. It has been removed with the release of phpunit/phpunit:10.0.0 on February 3, 2023.

Events in the hooks event system

The hooks event system in phpunit/phpunit:9.6.3 ships with eleven segregated interfaces, each of which defines a single method.

<?php

declare(strict_types=1);

namespace PHPUnit\Runner;

interface BeforeFirstTestHook extends Hook
{
    public function executeBeforeFirstTest(): void;
}
<?php

declare(strict_types=1);

namespace PHPUnit\Runner;

interface BeforeTestHook extends TestHook
{
    public function executeBeforeTest(string $test): void;
}
<?php

declare(strict_types=1);

namespace PHPUnit\Runner;

interface AfterTestHook extends TestHook
{
    public function executeAfterTest(
        string $test,
        float $time
    ): void;
}
<?php

declare(strict_types=1);

namespace PHPUnit\Runner;

interface AfterIncompleteTestHook extends TestHook
{
    public function executeAfterIncompleteTest(
        string $test,
        string $message,
        float $time
    ): void;
}
<?php

declare(strict_types=1);

namespace PHPUnit\Runner;

interface AfterRiskyTestHook extends TestHook
{
    public function executeAfterRiskyTest(
        string $test,
        string $message,
        float $time
    ): void;
}
<?php

declare(strict_types=1);

namespace PHPUnit\Runner;

interface AfterSkippedTestHook extends TestHook
{
    public function executeAfterSkippedTest(
        string $test,
        string $message,
        float $time
    ): void;
}
<?php

declare(strict_types=1);

namespace PHPUnit\Runner;

interface AfterSuccessfulTestHook extends TestHook
{
    public function executeAfterSuccessfulTest(
        string $test,
        float $time
    ): void;
}
<?php

declare(strict_types=1);

namespace PHPUnit\Runner;

interface AfterTestErrorHook extends TestHook
{
    public function executeAfterTestError(
        string $test,
        string $message,
        float $time
    ): void;
}
<?php

declare(strict_types=1);

namespace PHPUnit\Runner;

interface AfterTestFailureHook extends TestHook
{
    public function executeAfterTestFailure(
        string $test,
        string $message,
        float $time
    ): void;
}
<?php

declare(strict_types=1);

namespace PHPUnit\Runner;

interface AfterTestWarningHook extends TestHook
{
    public function executeAfterTestWarning(
        string $test,
        string $message,
        float $time
    ): void;
}
<?php

declare(strict_types=1);

namespace PHPUnit\Runner;

interface AfterLastTestHook extends Hook
{
    public function executeAfterLastTest(): void;
}

Unsurprisingly, each method corresponds to an event during a test run. When you have registered an extension with the hooks event system, phpunit/phpunit will invoke these methods as it sees fit.

Implementing an extension using the hooks event system

To implement an extension using the hooks event system, you must create one or more classes that implement one or more hook interfaces for events of your concern.

Here is an example of an implementation of an extension using the hooks event system from johnkary/phpunit-speedtrap:dev-master#a05623c (not yet released at the time of writing):

<?php

declare(strict_types=1);

namespace JohnKary\PHPUnit\Extension;

use PHPUnit\Runner;

class SpeedTrap implements
    Runner\AfterSuccessfulTestHook,
    Runner\BeforeFirstTestHook,
    Runner\AfterLastTestHook
{
    // ...

    public function executeAfterSuccessfulTest(
        string $test,
        float $time
    ): void {
        // ...
    }

    public function executeBeforeFirstTest(): void
    {
        // ...
    }

    public function executeAfterLastTest(): void
    {
        // ...
    }

    // ...
}

To allow the configuration of an extension using the hooks event system, you need to add a constructor to your extension:

<?php

declare(strict_types=1);

namespace JohnKary\PHPUnit\Extension;

use PHPUnit\Runner;

class SpeedTrap implements
    Runner\AfterSuccessfulTestHook,
    Runner\BeforeFirstTestHook,
    Runner\AfterLastTestHook
{
    // ...

    public function __construct(array $options = [])
    {
        // ...
    }

    // ...
}

Registering an extension using the hooks event system

To register an extension using the hooks event system, you need to configure it using the <extensions> and <extension> elements of phpunit.xml.

The following example registers johnkary/phpunit-speedtrap:dev-master#a05623c with default options:

<phpunit
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
    bootstrap="vendor/autoload.php"
>
    <extensions>
        <extension class="JohnKary\PHPUnit\Extension\SpeedTrap" />
    </extensions>
</phpunit>

The following example registers johnkary/phpunit-speedtrap:dev-master#a05623c with custom options:

<phpunit
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
    bootstrap="vendor/autoload.php"
>
    <extensions>
        <listener class="JohnKary\PHPUnit\Extension\SpeedTrap">
            <extension>
                <array>
                    <element key="slowThreshold">
                        <integer>250</integer>
                    </element>
                </array>
            </extension>
        </listener>
    </extensions>
</phpunit>

phpunit/phpunit provides facilities for casting values from the <arguments> element. phpunit/phpunit will pass these values to the constructor of the extension.

In the example above, phpunit/phpunit will pass the following value as $options argument to the constructor of SpeedTrapListener:

[
    'slowThreshold' => 250,
]

Migrating an extension from the test listener event system to the hooks event system

To migrate an extension from the test listener to the hooks event system, you need to require and support one ore more of phpunit/phpunit:^7.1.0, phpunit/phpunit:^8.0.0, and phpunit/phpunit:^9.0.0.

Next, instead of implementing the TestListener interface, you need to change the extension to implement one or more interfaces from the hooks event system. Consequently, you must remove the import of the TestListenerDefaultImplementation trait.

Furthermore, since the method signatures of the hook interfaces are different from the method signatures of the TestListener interface, you need to change the extension so it can work with scalars instead of mutable objects.

Moreover, since the extension can not modify test outcomes anymore, you need to remove functionality that attempts to do so. If the extension's purpose is solely the modification of test outcomes, it will not work with the test listener event system.

Finally, instead of using the <listeners> and <listener> elements, you need to instruct users of your extension to use the <extensions> and <extension> elements of phpunit.xml.

Here is a pull request by John Kary, migrating johnkary/phpunit-speedtrap from the test listener event system to the hooks event system.

Advantages of the hooks event system

The hooks event system has a few advantages compared to the test listener event system.

To implement hooks, you no longer need to create a class that implements methods for events that are not of your concern.

The methods of the hooks interfaces no longer accept mutable objects, so it is no longer possible to modify the outcome of a test run.

Disadvantages of the hooks event system

However, the hooks event system also has at least one disadvantage compared to the test listener event system.

Since the methods of the hooks interfaces only accept scalars, you have to do more work in an extension using the hooks event system yourself.

Instead of passing around scalars, the event system could benefit from additional test-related information and value objects.

Deprecation and removal of the hooks event system

The hooks event system has been deprecated since the release of phpunit/phpunit:8.5.23 on February 1, 2022. It has been removed with the release of phpunit/phpunit:10.0.0 on February 3, 2023.

New event system

The third and current iteration of the event system of phpunit/phpunit, the new event system, has been a long time coming and is finally available since the release of phpunit/phpunit:10.0.0 on February 3, 2023.

Initial work on the new event system started when the phpunit/phpunit development team - Sebastian Bergmann, Arne Blankerts, Stefan Priebsch, Ewout Pieter den Ouden, and I - attended the EU-FOSSA hackathon from October 5 to October 6, 2019, in Brussels.

Arne Blankerts, Ewout Pieter den Ouden, and Andreas Möller at the EU-FOSSA 2019 hackathon

Arne Blankerts, Andreas Möller, and Ewout Pieter den Ouden (from left to right) at the EU-FOSSA 2019 hackathon. Not pictured: Sebastian Bergmann (who took the picture) and Stefan Priebsch.

Find out more about the release of phpunit/phpunit:10.0.0 in the official release announcement.

Components in the new event system

The new event system in phpunit/phpunit:10.0.7 ships with an event Facade, an event Emitter interface, a DispatchingEmitter, a Dispatcher interface, a SubscribableDispatcher interface, a few implementations of the Dispatcher and SubscribableDispatcher interfaces, and an extension Facade.

The event Facade provides methods that allow registering subscribers and tracers and, most importantly, registers known events, and, most importantly, gives access to the event Emitter.

The event Facade is for internal use only.

<?php

declare(strict_types=1);

namespace PHPUnit\Event;

/**
 * @internal
 */
final class Facade
{
    // ...

    public static function registerSubscribers(Subscriber ...$subscribers): void
    {
        // ...
    }

    public static function registerSubscriber(Subscriber $subscriber): void
    {
        // ...
    }

    public static function registerTracer(Tracer\Tracer $tracer): void
    {
        // ...
    }

    public static function emitter(): Emitter
    {
        // ...
    }

    // ...
}

The event Emitter interface provides methods that allow emitting events.

The event Emitter is for internal use only.

<?php

declare(strict_types=1);

namespace PHPUnit\Event;

use PHPUnit\Framework;

/**
 * @internal
 */
interface Emitter
{
    public function applicationStarted(): void;

    // ...

    public function testAssertionFailed(
        mixed $value,
        Framework\Constraint\Constraint $constraint,
        string $message
    ): void;

    // ...

    public function applicationFinished(int $shellExitCode): void;
}

The DispatchingEmitter implements the event Emitter interface, creates events, and uses a Dispatcher to dispatch these events.

The DispatchingEmitter is for internal use only.

<?php

declare(strict_types=1);

namespace PHPUnit\Event;

use PHPUnit\Framework;

/**
 * @internal
 */
final class DispatchingEmitter implements Emitter
{
    // ...

    public function applicationStarted(): void
    {
        // ...
    }

    public function testAssertionFailed(
        mixed $value,
        Framework\Constraint\Constraint $constraint,
        string $message
    ): void {
        // ...
    }

    // ...

    public function applicationFinished(int $shellExitCode): void
    {
        // ...
    }

    // ...
}

The Dispatcher defines a single method for dispatching an event.

The Dispatcher interface is for internal use only.

<?php

declare(strict_types=1);

namespace PHPUnit\Event;

/**
 * @internal
 */
interface Dispatcher
{
    public function dispatch(Event $event): void;
}

The CollectingDispatcher implements the Dispatcher interface and collects events.

The CollectingDispatcher is for internal use only.

<?php

declare(strict_types=1);

namespace PHPUnit\Event;

/**
 * @internal
 */
final class CollectingDispatcher implements Dispatcher
{
    // ...

    public function dispatch(Event $event): void
    {
        // ...
    }

    public function flush(): EventCollection
    {
        // ...
    }
}

The SubscribableDispatcher interface extends the Dispatcher interface and defines methods for registering subscribers and tracers.

The SubscribableDispatcher interface is for internal use only.

<?php

declare(strict_types=1);

namespace PHPUnit\Event;

/**
 * @internal
 */
interface SubscribableDispatcher extends Dispatcher
{
    public function registerSubscriber(Subscriber $subscriber): void;

    public function registerTracer(Tracer\Tracer $tracer): void;
}

The DirectDispatcher implements the SubscribableDispatcher interface and immediately traces an event with all tracers, and notifies all subscribers that subscribe to an event of that event.

The DirectDispatcher is for internal use only.

<?php

declare(strict_types=1);

namespace PHPUnit\Event;

/**
 * @internal
 */
final class DirectDispatcher implements SubscribableDispatcher
{
    // ...

    public function registerTracer(Tracer\Tracer $tracer): void
    {
        // ...
    }

    public function registerSubscriber(Subscriber $subscriber): void
    {
        // ...
    }

    public function dispatch(Event $event): void
    {
        // ...
    }
}

The DeferringDispatcher implements the SubscribableDispatcher interface and defers tracing events and notifying subscribers of an event until it is flushed.

The DeferringDispatcher is for internal use only.

<?php

declare(strict_types=1);

namespace PHPUnit\Event;

/**
 * @internal
 */
final class DeferringDispatcher implements SubscribableDispatcher
{
    // ...

    public function registerTracer(Tracer\Tracer $tracer): void
    {
        // ...
    }

    public function registerSubscriber(Subscriber $subscriber): void
    {
        // ...
    }

    public function dispatch(Event $event): void
    {
        // ...
    }

    public function flush(): void
    {
        // ...
    }
}

The extension Facade provides methods for registering subscribers and traces.

<?php

declare(strict_types=1);

namespace PHPUnit\Runner\Extension;

use PHPUnit\Event;

final class Facade
{
    public function registerSubscribers(Event\Subscriber ...$subscribers): void
    {
        // ...
    }

    public function registerSubscriber(Event\Subscriber $subscriber): void
    {
        // ...
    }

    public function registerTracer(Event\Tracer $tracer): void
    {
        // ...
    }
}

Events in the new event system

In addition to the components above, the new event system in phpunit/phpunit:10.0.7 ships with an Event interface and sixty-three concrete events, a Subscriber interface and sixty-three event subscriber interfaces, a Tracer interface, and an Extension interface.

The Event interface declares methods that give access to basic telemetry information, such as duration and memory usage, and a string representation of an event.

<?php

declare(strict_types=1);

namespace PHPUnit\Event;

interface Event
{
    public function telemetryInfo(): Telemetry\Info;

    public function asString(): string;
}

All sixty-three events implement the Event interface and provide access to test-related information available when the event occurred and of interest to phpunit/phpunit internally and potential users of the new event system.

  • PHPUnit\Event\Application\Started
  • PHPUnit\Event\Application\Finished
  • PHPUnit\Event\Test\MarkedIncomplete
  • PHPUnit\Event\Test\AfterLastTestMethodCalled
  • PHPUnit\Event\Test\AfterLastTestMethodFinished
  • PHPUnit\Event\Test\AfterTestMethodCalled
  • PHPUnit\Event\Test\AfterTestMethodFinished
  • PHPUnit\Event\Test\AssertionSucceeded
  • PHPUnit\Event\Test\AssertionFailed
  • PHPUnit\Event\Test\BeforeFirstTestMethodCalled
  • PHPUnit\Event\Test\BeforeFirstTestMethodErrored
  • PHPUnit\Event\Test\BeforeFirstTestMethodFinished
  • PHPUnit\Event\Test\BeforeTestMethodCalled
  • PHPUnit\Event\Test\BeforeTestMethodFinished
  • PHPUnit\Event\Test\ComparatorRegistered
  • PHPUnit\Event\Test\ConsideredRisky
  • PHPUnit\Event\Test\DeprecationTriggered
  • PHPUnit\Event\Test\Errored
  • PHPUnit\Event\Test\ErrorTriggered
  • PHPUnit\Event\Test\Failed
  • PHPUnit\Event\Test\Finished
  • PHPUnit\Event\Test\NoticeTriggered
  • PHPUnit\Event\Test\Passed
  • PHPUnit\Event\Test\PhpDeprecationTriggered
  • PHPUnit\Event\Test\PhpNoticeTriggered
  • PHPUnit\Event\Test\PhpunitDeprecationTriggered
  • PHPUnit\Event\Test\PhpunitErrorTriggered
  • PHPUnit\Event\Test\PhpunitWarningTriggered
  • PHPUnit\Event\Test\PhpWarningTriggered
  • PHPUnit\Event\Test\PostConditionCalled
  • PHPUnit\Event\Test\PostConditionFinished
  • PHPUnit\Event\Test\PreConditionCalled
  • PHPUnit\Event\Test\PreConditionFinished
  • PHPUnit\Event\Test\PreparationStarted
  • PHPUnit\Event\Test\Prepared
  • PHPUnit\Event\Test\Skipped
  • PHPUnit\Event\Test\WarningTriggered
  • PHPUnit\Event\Test\MockObjectCreated
  • PHPUnit\Event\Test\MockObjectForAbstractClassCreated
  • PHPUnit\Event\Test\MockObjectForIntersectionOfInterfacesCreated
  • PHPUnit\Event\Test\MockObjectForTraitCreated
  • PHPUnit\Event\Test\MockObjectFromWsdlCreated
  • PHPUnit\Event\Test\PartialMockObjectCreated
  • PHPUnit\Event\Test\TestProxyCreated
  • PHPUnit\Event\Test\TestStubCreated
  • PHPUnit\Event\Test\TestStubForIntersectionOfInterfacesCreated
  • PHPUnit\Event\TestRunner\BootstrapFinished
  • PHPUnit\Event\TestRunner\Configured
  • PHPUnit\Event\TestRunner\EventFacadeSealed
  • PHPUnit\Event\TestRunner\ExecutionFinished
  • PHPUnit\Event\TestRunner\ExecutionStarted
  • PHPUnit\Event\TestRunner\ExtensionLoadedFromPhar
  • PHPUnit\Event\TestRunner\ExtensionBootstrapped
  • PHPUnit\Event\TestRunner\Finished
  • PHPUnit\Event\TestRunner\Started
  • PHPUnit\Event\TestRunner\DeprecationTriggered
  • PHPUnit\Event\TestRunner\WarningTriggered
  • PHPUnit\Event\TestSuite\Filtered
  • PHPUnit\Event\TestSuite\Finished
  • PHPUnit\Event\TestSuite\Loaded
  • PHPUnit\Event\TestSuite\Skipped
  • PHPUnit\Event\TestSuite\Sorted
  • PHPUnit\Event\TestSuite\Started

Here is the AssertionFailed event:

<?php

declare(strict_types=1);

namespace PHPUnit\Event\Test;

use PHPUnit\Event;

final class AssertionFailed implements Event\Event
{
    // ...

    public function telemetryInfo(): Event\Telemetry\Info
    {
        return $this->telemetryInfo;
    }

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

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

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

    public function asString(): string
    {
        $message = '';

        if (!empty($this->message)) {
            $message = sprintf(
                ', Message: %s',
                $this->message
            );
        }

        return sprintf(
            'Assertion Failed (Constraint: %s, Value: %s%s)',
            $this->constraint,
            $this->value,
            $message
        );
    }
}

The Subscriber interface is a marker interface for all sixty-three event subscribers.

<?php

declare(strict_types=1);

namespace PHPUnit\Event;

interface Subscriber
{
}

Here is the AssertionFailedSubscriber interface:

<?php

declare(strict_types=1);

namespace PHPUnit\Event\Test;

use PHPUnit\Event;

interface AssertionFailedSubscriber extends Event\Subscriber
{
    public function notify(AssertionFailed $event): void;
}

The Tracer interface declares a single method that allows phpunit/phpunit to notify a concrete tracer about every event that occurred during the execution of PHPUnit.

<?php

declare(strict_types=1);

namespace PHPUnit\Event\Tracer;

use PHPUnit\Event;

interface Tracer
{
    public function trace(Event\Event $event): void;
}

The Extension interface declares a method that accepts an immutable configuration object, an extension facade, and an immutable parameter collection and allows to register event subscribers and event tracers with the new event system of phpunit/phpunit.

<?php

declare(strict_types=1);

namespace PHPUnit\Runner\Extension;

use PHPUnit\TextUI;

interface Extension
{
    public function bootstrap(
        TextUI\Configuration\Configuration $configuration,
        Facade $facade,
        ParameterCollection $parameters
    ): void;
}

Implementing event subscribers in the new event system

To implement an event subscriber in the new event system, you must create a class that implements an event subscriber interface corresponding to the event.

By design, an event subscriber processes a single event only.

If you are interested in more than one event, you must create one or more subscribers per event.

Here is an example of an implementation of an event subscriber using the new event system from localheinz/phpunit-speedtrap:dev-feature-phpunit-10 (from a pull request that has not yet been merged at the time of writing):

<?php

declare(strict_types=1);

namespace JohnKary\PHPUnit\Extension;

use PHPUnit\Event;

final class RecordThatTestHasBeenPrepared implements Event\Test\PreparedSubscriber
{
    public function __construct(private readonly SpeedTrap $speedTrap)
    {
    }

    public function notify(Event\Test\Prepared $event): void
    {
        $this->speedTrap->recordThatTestHasBeenPrepared(
            $event->test(),
            $event->telemetryInfo()->time(),
        );
    }
}

Here is another example of an implementation of an event subscriber using the new event system from localheinz/phpunit-speedtrap:dev-feature-phpunit-10 (from the same pull request):

<?php

declare(strict_types=1);

namespace JohnKary\PHPUnit\Extension;

use PHPUnit\Event;

final class RecordThatTestHasPassed implements Event\Test\PassedSubscriber
{
    public function __construct(private readonly SpeedTrap $speedTrap)
    {
    }

    public function notify(Event\Test\Passed $event): void
    {
        $this->speedTrap->recordThatTestHasPassed(
            $event->test(),
            $event->telemetryInfo()->time(),
        );
    }
}

Here is one more example of an implementation of an event subscriber using the new event system from localheinz/phpunit-speedtrap:dev-feature-phpunit-10 (from the same pull request):

<?php

declare(strict_types=1);

namespace JohnKary\PHPUnit\Extension;

use PHPUnit\Event;

final class ShowSlowTests implements Event\TestRunner\ExecutionFinishedSubscriber
{
    public function __construct(private readonly SpeedTrap $speedTrap)
    {
    }

    public function notify(Event\TestRunner\ExecutionFinished $event): void
    {
        $this->speedTrap->showSlowTests();
    }
}

As you can see, the event subscribers are small. Each event subscriber serves a simple purpose and delegates the actual work to another service.

How you design your event subscribers largely depends on whether you need to share state between them.

Implementing event tracers in the new event system

To implement an event tracer in the new event system, you must create a class that implements the Tracer interface.

By design, an event tracer processes all events.

Unless you are interested in all events, you probably want to implement an event subscriber instead.

Here is the EventLogger, an example of an implementation of a tracer from phpunit/phpunit:10.0.7.

<?php

declare(strict_types=1);

namespace PHPUnit\Logging;

use PHPUnit\Event;

final class EventLogger implements Event\Tracer\Tracer
{
    // ...

    public function trace(Event\Event $event): void
    {
        $telemetryInfo = $this->telemetryInfo($event);
        $indentation   = PHP_EOL . str_repeat(' ', strlen($telemetryInfo));
        $lines         = explode(PHP_EOL, $event->asString());

        file_put_contents(
            $this->path,
            $telemetryInfo . implode($indentation, $lines) . PHP_EOL,
            FILE_APPEND | LOCK_EX
        );
    }

    // ...
}

The EventLogger is one of the first implementations in phpunit/phpunit:10.0.0 that uses the new event system. It dumps string representations of all events into a text file.

Implementing an extension in the new event system

To allow others to register your event subscribers or event tracers, you must create a class that implements the Extension interface.

Here is an example of an implementation of an extension using the new event system from localheinz/phpunit-speedtrap:dev-feature-phpunit-10 (from a pull request that has not yet been merged at the time of writing) with default options:

<?php

declare(strict_types=1);

namespace JohnKary\PHPUnit\Extension;

use PHPUnit\Runner;
use PHPUnit\TextUI;

final class SpeedTrapExtension implements Runner\Extension\Extension
{
    public function bootstrap(
        TextUI\Configuration\Configuration $configuration,
        Runner\Extension\Facade $facade,
        Runner\Extension\ParameterCollection $parameters
    ): void {
        if (getenv('PHPUNIT_SPEEDTRAP') === 'disabled') {
            return;
        }

        $slowThreshold = 500;

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

        $reportLength = 10;

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

        $speedTrap = new SpeedTrap(
            $slowThreshold,
            $reportLength
        );

        $facade->registerSubscribers(
            new RecordThatTestHasBeenPrepared($speedTrap),
            new RecordThatTestHasPassed($speedTrap),
            new ShowSlowTests($speedTrap),
        );
    }
}

As you can see from the example above, the SpeedTrapExtension checks whether an environment variable was set to determine whether it should register itself with the extension facade or not.

Next, it declares default configuration values and inspects the parameter collection to determine whether it should override any default configuration values.

Finally, it creates a service and event subscribers and registers these event subscribers using the extension facade.

Registering an extension using the new event system

To register an extension using the new event system, you need to configure it using the <extensions>, <extension>, and <parameter> elements of phpunit.xml.

The following example registers localheinz/phpunit-speedtrap:dev-feature-phpunit-10 (from a pull request that has not yet been merged at the time of writing) with default options:

<phpunit
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
    bootstrap="vendor/autoload.php"
>
    <extensions>
        <bootstrap class="JohnKary\PHPUnit\Extension\SpeedTrapExtension" />
    </extensions>
</phpunit>

The following example registers localheinz/phpunit-speedtrap:dev-feature-phpunit-10 (from a pull request that has not yet been merged at the time of writing) with custom options:

<phpunit
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
    bootstrap="vendor/autoload.php"
>
    <extensions>
        <bootstrap class="JohnKary\PHPUnit\Extension\SpeedTrapExtension">
            <parameter name="slowThreshold" value="500" />
        </bootstrap>
    </extensions>
</phpunit>

Migrating an extension from the hooks event system to the new event system

To migrate an extension from the hooks event system to the new event system, you need to require and support phpunit/phpunit:^10.0.0.

Next, instead of implementing the hook interfaces, you need to extract event subscribers from your extension that subscribe to the corresponding events of the new event system.

Additionally, you must create a class that implements the Extension interface and registers your event subscribers. If your extension is configurable, it needs to inspect the parameter collection and process parameters from there instead of relying on phpunit/phpunit to cast values from phpunit.xml.

Finally, instead of using the <extensions> and <extension> elements, you need to instruct users of your extension to use the <extensions> and <bootstrap> elements of phpunit.xml.

Here is a pull request from me, migrating johnkary/phpunit-speedtrap from the hooks event system to the new event system. Does the code look familiar? You have seen it before in this article!

Advantages of the new event system

While implementing the new event system, the phpunit/phpunit development team identified many new events. The previous test listener and hooks event systems are aware of less than a dozen events. The new event system has more granularity, with a whopping sixty-three events.

All of these events are immutable and provide access to more data with higher accuracy.

Using the new event system internally, the phpunit/phpunit development team cleaned up a lot of code and simplified maintenance.

Disadvantages of the new event system

Admittedly, an obvious disadvantage of the new event system is that developers interested in supporting the new event system need to make some effort to migrate existing extensions.

Questions

Do you have any questions regarding the new event system of phpunit/phpunit? Do you have any feedback? Do you know of any exciting new extensions based on PHPUnit's new event system?

Project on GitHub

ergebnis/phpunit-slow-test-detector

Integrate WorkflowCode CoverageType CoverageLatest Stable VersionTotal DownloadsMonthly Downloads

⏱️ Provides composer package with an extension for detecting slow tests in phpunit/phpunit.

Find out more at ergebnis/phpunit-slow-test-detector.

Do you find this article helpful?

Do you have feedback?

Do you need help with your PHP project?