Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[FR] Handle expectDeprecation*() et al methods removed by PHPUnit 10 #186

Closed
hellofromtonya opened this issue Sep 5, 2024 · 3 comments · Fixed by #187
Closed

[FR] Handle expectDeprecation*() et al methods removed by PHPUnit 10 #186

hellofromtonya opened this issue Sep 5, 2024 · 3 comments · Fixed by #187

Comments

@hellofromtonya
Copy link
Collaborator

hellofromtonya commented Sep 5, 2024

PHPUnit 10 removes the following methods:

  • expectDeprecation(), expectDeprecationMessage(), and expectDeprecationMessageMatches()
  • expectError(), expectErrorMessage(), and expectErrorMessageMatches()
  • expectNotice(), expectNoticeMessage(), and expectNoticeMessageMatches()
  • expectWarning(), expectWarningMessage(), and expectWarningMessageMatches()

There is a valid use case for testing your application's triggered deprecations (as well as notices, warnings, etc.).

For deprecations, PHPUnit 11 introduces expectUserDeprecation*() methods, which are similar though not exactly the same as expectDeprecation*() removed in PHPUnit 10.

With PHPUnit 10 removal, polyfilling these removed methods does not fit within this project's forward compatible approach. That said, knowing the impacts and likely needs of projects, let's explore:

  • Could or should Polyfills handle these polyfills?
  • Could a possible approach be provided as a guide for those needing a polyfill?

Ref: https://github.com/Yoast/PHPUnit-Polyfills/tree/1.x?tab=readme-ov-file#phpunit--840-yoastphpunitpolyfillspolyfillsexpectphpexception

@hellofromtonya hellofromtonya changed the title [FR] Custom Approach for handling expectDeprecation*() [FR] Handle expect*() methods removed by PHPUnit 10 Sep 6, 2024
@hellofromtonya hellofromtonya changed the title [FR] Handle expect*() methods removed by PHPUnit 10 [FR] Handle expectExpectation*() methods removed by PHPUnit 10 Sep 6, 2024
@hellofromtonya hellofromtonya changed the title [FR] Handle expectExpectation*() methods removed by PHPUnit 10 [FR] Handle expectDeprecation*() et all methods removed by PHPUnit 10 Sep 6, 2024
@hellofromtonya hellofromtonya changed the title [FR] Handle expectDeprecation*() et all methods removed by PHPUnit 10 [FR] Handle expectDeprecation*() et al methods removed by PHPUnit 10 Sep 6, 2024
@jrfnl
Copy link
Collaborator

jrfnl commented Sep 6, 2024

@hellofromtonya I get where you are coming from, but as you already mentioned: this library makes the tests forward compatible - i.e. it polyfills new PHPUnit functionality -, not backward compatible (polyfill removed functionality).

With that in mind, as PHPUnit 11 will introduce the expectUserDeprecation*() methods, I think polyfilling those is something suitable for PHPUnit Polyfills 3.0 (also see the Roadmap for 3.x), which will support PHPUnit 11, but not for the 2.x range, which only supports up to PHPUnit 10.

That still doesn't address the removed expect.. functionality for warnings/notices, but PHPUnit 10 and higher handles those differently anyway (they don't fail the test run by default anymore, though this can be toggled back on) and as it doesn't fit the premise of forward compatibility, I don't think the Polyfills should touch this.

I have no objection to linking to another PHPUnit add-on package which does polyfill it (from the FAQ) or to a tutorial which shows users how to do it. Or even to a comment in this thread with an explanation. But I don't think the PHPUnit Polyfills package should polyfill it in 2.x and for 3.x, only the (new) functionality added in PHPUnit 11 should be polyfilled IMO.

@hellofromtonya
Copy link
Collaborator Author

Custom Approach Tutorial

The following custom approach tutorial is a Proof of Concept (PoC) to help guide developers who need to these expects in their project.

This tutorial will cover how to polyfill expectDeprecation(), expectDeprecationMessage(), and expectDeprecationMessageMatches() methods. Developers can use this as a model for polyfilling any of the removed methods they need.

There are multiple ways to polyfill these expectations. The custom approach, presented here, uses a custom extension and the events system.

This custom approach:

The following sections provides guidance for you to adapt this custom approach for your application.

Disclaimer: As a PoC, this is not guaranteed or warranted. Developers will need to tailor it to their specific needs.

Overview

The building blocks of this custom polyfill are:

  1. A polyfill trait.
  2. A subscriber to capture your application's deprecations.
  3. A subscriber to verify deprecation expectations.
  4. A subscriber to reset between tests.
  5. A custom extension to extend the Test Runner, register each of the subscribers, and wire the building blocks together.

This approach will use the following naming:

  • Namespace: Vendor\YourPackage\Deprecations.
  • Polyfill trait: Vendor\YourPackage\Deprecations\ExpectDeprecation.
  • Deprecations subscriber: Vendor\YourPackage\Deprecations\DeprecationSubscriber.
  • Verify deprecation expectations subscriber: Vendor\YourPackage\Deprecations\VerifyDeprecationExpectationsSubscriber.
  • Reset subscriber: Vendor\YourPackage\Deprecations\ResetSubscriber.
  • Extension: Vendor\YourPackage\Deprecations\DeprecationExtension.

Basic call stack

When a test invokes one of the polyfills, for example:

  • -> expectDeprecationMessage()
  • -> stores the given expected deprecation message in ExpectDeprecation::$expectedDeprecationMessage
  • -> ExpectDeprecation::setUpExpectDeprecation()
  • -> DeprecationExtension::setTestInstance() to bind the current test to the extension.

When a deprecation is triggered,

  • -> PHPUnit event PHPUnit\Event\Test\DeprecationTriggered fires
  • -> DeprecationSubscriber::notify()
  • -> DeprecationExtension::collect() which stores the deprecation in a property.

When the test is prepared for execution:

  • -> PHPUnit's event PHPUnit\Event\Test\TestPrepared
  • -> Vendor\YourPackage\Deprecations\ResetSubscriber::notify()
  • -> DeprecationExtension::reset() which resets the collection of actual deprecations triggered
  • -> ExpectDeprecation::resetExpectedDeprecationMessages() which resets the collection of expected deprecations.

Just before the test completes:

  • -> PHPUnit event AfterTestMethodFinishedSubscriber fires
  • -> VerifyDeprecationExpectationsSubscriber::notify()
  • -> DeprecationExtension::verify()
  • -> ExpectDeprecation::resetExpectedDeprecationMessages() which loops through its stored expectations in ExpectDeprecation::$expectedDeprecationMessage
    • If DeprecationExtension::triggered() is true, then it passes.
    • Else
      • -> throws a new ExpectationFailedException()
      • -> fails the test.

Custom polyfills trait

This custom trait polyfills each of the expected deprecations. You'll drop into each test class that tests for deprecations.

Please note, following example is heavily influenced by and in many cases copied from PHPUnit 11's implementation for its handling. When using, ensure to give proper credit.

Code for ExpectDeprecation polyfills trait
<?php

namespace Vendor\YourPackage\Deprecations;

use PHPUnit\Event\Code\ComparisonFailureBuilder;
use PHPUnit\Event\Code\ThrowableBuilder;
use PHPUnit\Event\Facade as EventFacade;
use PHPUnit\Framework\ExpectationFailedException;

trait ExpectDeprecation
{
    protected $expectedDeprecations = 0;
    protected $expectedDeprecationMessage = [];
    protected $expectedDeprecationMessageRegularExpression = [];

    final protected function expectDeprecation(): void
    {
        $this->setUpExpectDeprecation();   
        $this->expectedDeprecations++;
    }

    final protected function expectDeprecationMessage($expectedDeprecationMessage): void
    {
        $this->setUpExpectDeprecation();
        $this->expectedDeprecationMessage[$expectedDeprecationMessage] = false;
    }
    
    final protected function expectDeprecationMessage($expectedDeprecationMessage): void
    {
        $this->setUpExpectDeprecation();
        $this->expectedDeprecationMessage[$expectedDeprecationMessage] = false;
    }

    final protected function expectDeprecationMessageMatches($expectedDeprecationMessageRegularExpression): void
    {
        $this->setUpExpectDeprecation();
        $this->expectedDeprecationMessageRegularExpression[] = $expectedDeprecationMessageRegularExpression;
    }

    final protected function expectDeprecationMessageMatches($expectedDeprecationMessageRegularExpression): void
    {
        $this->setUpExpectDeprecation();
        $this->expectedDeprecationMessageRegularExpression[] = $expectedDeprecationMessageRegularExpression;
    }

    private function setUpExpectDeprecation(): void
    {
        DeprecationExtension::setTestInstance($this);
        $this->addToAssertionCount(1);
    }

    final public function verifyDeprecationExpectations(): void
    {
        if ($this->expectedDeprecations > 0)
        {
            if (!empty( DeprecationExtension::deprecations() ))
            {
                static::assertThat(true, static::isTrue());
            }
            else
            {
                $this->failExpectDeprecationTest('Expected deprecation was not triggered');
            }
        }

        foreach (array_keys($this->expectedDeprecationMessage) as $deprecationExpectation)
        {
            if (DeprecationExtension::triggered($deprecationExpectation))
            {
                static::assertThat(true, static::isTrue());
            }
            else
            {
                $this->failExpectDeprecationTest(
                    sprintf(
                        'Expected deprecation with message "%s" was not triggered',
                        $deprecationExpectation,
                    )
                );
            }
        }
    }

    public function resetExpectedDeprecationMessages(): void
    {
        $this->expectedDeprecationMessage = [];
        $this->expectedDeprecationMessageRegularExpression = [];
    }

    private function failExpectDeprecationTest($message): void
    {
        $e = new ExpectationFailedException($message);
        EventFacade::emitter()->testFailed(
            $this->valueObjectForEvents(),
            ThrowableBuilder::from($e),
            ComparisonFailureBuilder::from($e),
        ); 
    }
}

Custom extension

Refer to "Implementing an extension" in the PHPUnit 10 documentation.

The extension extends the Test Runner. It implements PHPUnit\Runner\Extension\Extension interface, which declares a bootstrap() method for registering the subscribers.

<?php

namespace Vendor\YourPackage\Deprecations;

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

class DeprecationExtension implements PHPUnitExtension
{
    public function bootstrap(Configuration $configuration, Facade $facade, ParameterCollection $parameters): void
    {
        $facade->registerSubscribers(
            new DeprecationSubscriber(),
            new VerifyDeprecationExpectationsSubscriber(),
            new ResetSubscriber()
         );
    }
}

For simplicity of documenting this example, it uses a singleton approach for the subscribers and polyfill to interact with it.

    private static $instance;

    public static function instance(): self
    {
        if ( ! self::$instance ) {
            self::$instance = new self();
        }
        return self::$instance;
    }

To bind the current test to the extension:

    private $testInstance;

    public static function setTestInstance($testInstance): void
    {
        self::instance()->testInstance = $testInstance;
    }

While a Collector could be used, for this example, the collection of actual deprecations is within the extension:

    protected array $actualDeprecations = [];

    public static function collect(DeprecationTriggered $event): void
    {
        $message = $event->message();
        self::instance()->actualDeprecations[$message] = true;
    }

The verify() method is available to wire together the event with the test's passing or failing of the expectation. The triggered() method is available for the test's expectation to check if a specific message was triggered.

    public static function verify(): void
    {
        self::instance()->testInstance->verifyDeprecationExpectations();
    }

    public static function triggered($message): bool
    {
        return isset(self::instance()->actualDeprecations[$message]);
    }
Code for DeprecationExtension custom extension
<?php

namespace Vendor\YourPackage\Deprecations;

use PHPUnit\Runner\Extension\Extension as PHPUnitExtension;
use PHPUnit\TextUI\Configuration\Configuration;
use PHPUnit\Runner\Extension\Facade;
use PHPUnit\Runner\Extension\ParameterCollection;
use PHPUnit\Event\Test\DeprecationTriggered;

class DeprecationExtension implements PHPUnitExtension
{
    private static $instance;
    protected array $actualDeprecations = [];
    private $testInstance;

    public function bootstrap(Configuration $configuration, Facade $facade, ParameterCollection $parameters): void
    {
        $facade->registerSubscribers(
            new DeprecationSubscriber(),
            new VerifyDeprecationExpectationsSubscriber(),
            new ResetSubscriber()
         );
    }

    public static function instance(): self
    {
        if ( ! self::$instance ) {
            self::$instance = new self();
        }
        return self::$instance;
    }

    public static function triggered($message): bool
    {
        return isset(self::instance()->actualDeprecations[$message]);
    }

    public static function collect(DeprecationTriggered $event): void
    {
        $message = $event->message();
        self::instance()->actualDeprecations[$message] = true;
    }

    public static function setTestInstance($testInstance): void
    {
        self::instance()->testInstance = $testInstance;
    }

    public static function verify(): void
    {
        self::instance()->testInstance->verifyDeprecationExpectations();
    }

    public static function reset(): void
    {
        $instance = self::instance();
        $instance->actualDeprecations = [];
        if ($instance->testInstance)
        {
            $instance->testInstance->resetExpectedDeprecationMessages();
        }
    }
}

Registering the extension

To make PHPUnit aware of your extension, you'll need need to register it in the PHPUnit XML configuration file.

For this custom extension example, the following is added:

<extensions>
    <bootstrap class="Vendor\YourPackage\Deprecations\DeprecationExtension"/>
</extensions>

If your extension is shared from a PHAR, please refer to the PHPUnit 10 documentation for how to register it.

Deprecations subscriber: Capturing your application's deprecations

The event system includes several deprecation triggered events.

You'll use the PHPUnit\Event\Test\DeprecationTriggered event to capture your application's deprecations. This event has a subscriber interface called PHPUnit\Event\Test\DeprecationTriggeredSubscriber.

You'll add a custom subscriber that implements this interface, e.g. Vendor\YourPackage\Deprecations\DeprecationSubscriber.

Code for DeprecationSubscriber
<?php

namespace Vendor\YourPackage\Deprecations;

use PHPUnit\Event\Test\DeprecationTriggeredSubscriber;
use PHPUnit\Event\Test\DeprecationTriggered;

class DeprecationSubscriber implements DeprecationTriggeredSubscriber
{
    public function notify(DeprecationTriggered $event): void
    {
        DeprecationExtension::collect($event);
    }
}

Verify deprecation expectations subscriber

This subscriber invokes the verify deprecation expectations process.

You'll use the PHPUnit\Event\Test\AfterTestMethodFinished event, which has a subscriber interface called PHPUnit\Event\Test\AfterTestMethodFinishedSubscriber.

Code for VerifyDeprecationExpectationsSubscriber
<?php

namespace Vendor\YourPackage\Deprecations;

use PHPUnit\Event\Test\AfterTestMethodFinishedSubscriber;
use PHPUnit\Event\Test\AfterTestMethodFinished;

class VerifyDeprecationExpectationsSubscriber implements AfterTestMethodFinishedSubscriber
{
    public function notify(AfterTestMethodFinished $event): void
    {
        DeprecationExtension::verify($event);
    }
}

Reset subscriber: reset between tests

This subscriber resets between tests.

You'll use the PHPUnit\Event\Test\Prepared event, which has a subscriber interface called PHPUnit\Event\Test\PreparedSubscriber.

Code for ResetSubscriber
<?php

namespace Vendor\YourPackage\Deprecations;

use PHPUnit\Event\Test\Prepared;
use PHPUnit\Event\Test\PreparedSubscriber;

class ResetSubscriber implements PreparedSubscriber
{
    public function notify(Prepared $event): void
    {
        DeprecationExtension::reset();
    }
}

@jrfnl
Copy link
Collaborator

jrfnl commented Sep 6, 2024

Thanks @hellofromtonya That looks amazing!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging a pull request may close this issue.

2 participants