Problem/Motivation

Spin off from #3268932-49: Add methods to assert status messages to WebAssert

From https://phpunit.readthedocs.io/en/9.5/textui.html:

For each test run, the PHPUnit command-line tool prints one character to indicate progress:

. Printed when the test succeeds.

F Printed when an assertion fails while running the test method.

E Printed when an error occurs while running the test method.

and

PHPUnit distinguishes between failures and errors. A failure is a violated PHPUnit assertion such as a failing assertSame() call. An error is an unexpected exception or a PHP error. Sometimes this distinction proves useful since errors tend to be easier to fix than failures. If you have a big list of problems, it is best to tackle the errors first and see if you have any failures left when they are all fixed.

In current Drupal, WebAssert methods, in case of failure, throw a Mink exception which in PHPUnit are reported as errors and not failures. This is a bit confusing when looking at test run reports because the assertion in these cases are actually checking a condition.

Proposed resolution

Catch Mink's ExpectationException in BrowserTestBase and re-throw a PHPUnit AssertionFailedError instead, ensuring the stack trace remains intact as this is critical to debugging test failures.

Remaining tasks

User interface changes

API changes

Data model changes

Release notes snippet

Issue fork drupal-3271214

Command icon Show commands

Start within a Git clone of the project using the version control instructions.

Or, if you do not have SSH keys set up on git.drupalcode.org:

Comments

mondrake created an issue. See original summary.

mondrake’s picture

Title: Change WebAssert::assert() to throw PHPUnit failures » Change WebAssert implementation to flag PHPUnit failures instead of throwing Mink exceptions

Changing WebAssert::assert() is not enough: it will only fit for the assertions called within Drupal's override of the class. When we use methods defined on the parent Mink's WebAssert, the assert() method used is Mink's one, which throws Mink exceptions. So we would need to wrap the calls to such methods in try...catch blocks in the overrides of the parent methods.

mondrake’s picture

Assigned: Unassigned » mondrake
Status: Needs review » Needs work
Issue tags: +Needs reroll

on this

mondrake’s picture

Issue summary: View changes
mondrake’s picture

Assigned: mondrake » Unassigned
Status: Needs work » Needs review
Issue tags: -Needs reroll

SAme as #4,

There's more to do, but since it's green now changing to NR to get feedback... does this make sense?

alexpott’s picture

I wonder if there is a way to indicate to PHPUnit that ExpectationException is a failure and not an error. It feels like something that anything using Mink with PHPUnit would want to be able to do. I'm wondering if we're fixing this the right way around.

mondrake’s picture

Status: Needs review » Postponed

Thanks. I'm not aware of a mechanism to 'register' exceptions in PHPUnit so that they are interpreted as AssertionFailureErrors. Maybe that could be done with listeners in PHPUnit 9, but listeners are a kinda blind alley right now, they're deprecated and will no longer exist in PHPUnit 10, where they're replaced with the event system. So we need to wait and see I guess.

longwave’s picture

Instead of changing WebAssert can we just wrap the exception in BTB? Entirely untested but something like:

  protected function runTest() {
    try {
      return parent::runTest();
    }
    catch (ExpectationException $e) {
      throw new AssertionFailedError($e->getMessage(), $e->getCode(), $e);
    }
  }
longwave’s picture

Status: Postponed » Needs review
StatusFileSize
new1.35 KB

This does seem to work, but breaks the PHPUnit stack trace - onNotSuccessfulTest() fixes that but then reverts to an error :/

mondrake’s picture

Nice try! The risk I see here is that overriding PHPUnit's methods we get exposed to future changes in PHPUnit itself.

For instance, in PHPUnit 10, PHPUnit\Framework\TestCase::addWarning() was made final so we can not override it any longer in Drupal\Tests\Traits\ PhpUnitWarnings. See #3217904-47: [meta] Support PHPUnit 10 in Drupal 11.

longwave’s picture

Status: Needs review » Needs work

So this is probably doable, but involves a lot of fighting against PHPUnit and Symfony.

onNotSuccessfulTest() alone can be used to catch and rethrow an ExpectationException as an AssertionFailedError, but this loses the stack trace, even when setting the previous exception - this is because the previous exception is lost when PHPUnit serializes the exception via the process isolation mechanism.

The trace is stored in the constructor of AssertionFailedError, so I tried extending that and throwing a subclassed exception, but then the Symfony debug loader gets in the way and tells me that AssertionFailedError is internal and shouldn't be extended. Also PHPUnit seems to check the exception type directly and doesn't handle my subclass properly somewhere, so the trace is no longer printed at all!

longwave’s picture

Status: Needs work » Needs review
StatusFileSize
new1.45 KB

Reflection to the rescue, this is a horrible hack but seems to work. If I break NodeTitleTest:

Without the patch:

$ ddev exec vendor/bin/phpunit -c core core/modules/node/tests/src/Functional/NodeTitleTest.php
PHPUnit 9.5.20 #StandWithUkraine

Testing Drupal\Tests\node\Functional\NodeTitleTest
E                                                                   1 / 1 (100%)

Time: 00:07.227, Memory: 4.00 MB

There was 1 error:

1) Drupal\Tests\node\Functional\NodeTitleTest::testNodeTitle
Behat\Mink\Exception\ExpectationException: Title found

/var/www/html/drupal/core/tests/Drupal/Tests/WebAssert.php:540
/var/www/html/drupal/core/tests/Drupal/Tests/WebAssert.php:283
/var/www/html/drupal/core/modules/node/tests/src/Functional/NodeTitleTest.php:94
/var/www/html/drupal/vendor/phpunit/phpunit/src/Framework/TestResult.php:726

ERRORS!
Tests: 1, Assertions: 17, Errors: 1.

With the patch:

$ ddev exec vendor/bin/phpunit -c core core/modules/node/tests/src/Functional/NodeTitleTest.php
PHPUnit 9.5.20 #StandWithUkraine

Testing Drupal\Tests\node\Functional\NodeTitleTest
F                                                                   1 / 1 (100%)

Time: 00:06.429, Memory: 4.00 MB

There was 1 failure:

1) Drupal\Tests\node\Functional\NodeTitleTest::testNodeTitle
Title found

/var/www/html/drupal/core/tests/Drupal/Tests/BrowserTestBase.php:397
/var/www/html/drupal/core/tests/Drupal/Tests/WebAssert.php:283
/var/www/html/drupal/core/modules/node/tests/src/Functional/NodeTitleTest.php:94
/var/www/html/drupal/vendor/phpunit/phpunit/src/Framework/TestResult.php:726

FAILURES!
Tests: 1, Assertions: 17, Failures: 1.

It would be cleaner to subclass AssertionFailedError but I don't see how to do that without the debug class loader throwing a deprecation because the class is internal, which gets turned into an error at this point.

Status: Needs review » Needs work

The last submitted patch, 14: 3271214-14.patch, failed testing. View results

mondrake’s picture

Actually this looks very very nice to me, kudos @longwave!

+++ b/core/tests/Drupal/Tests/BrowserTestBase.php
@@ -387,6 +389,24 @@ protected function setUp(): void {
+      $reflection->setAccessible(TRUE);

I think we do not need this with PHP 8.1.

Still a voice in the back of my head tells me to be aware this may change with PHPUnit 10, but let's see that when time comes.

mondrake’s picture

IS needs an update for the new direction.

mondrake’s picture

... and then I think it would be worth un-doing here the try-catch blocks in Drupal's WebAssert extension that are therefore no longer needed.

longwave’s picture

Issue summary: View changes
Issue tags: -Needs issue summary update

Updated IS. Forgot that setAccessible() is redundant, NW for that and #18.

longwave’s picture

Title: Change WebAssert implementation to flag PHPUnit failures instead of throwing Mink exceptions » Change BrowserTestBase to flag PHPUnit failures instead of throwing Mink exceptions
alexpott’s picture

Personally I feel this is worth an upstream issue to ask if there is a way to mark specific exceptions as fails and not errors. It would make integrating PHPUnit with things like mink easier.

Potentially we could use our \Drupal\TestTools\PhpUnitCompatibility\ClassWriter to copy \Behat\Mink\Exception\ExpectationException and make it extend \PHPUnit\Framework\AssertionFailedError instead of exception. But maybe this would fall foul in the same way as reported in #13.

longwave’s picture

Opened https://github.com/sebastianbergmann/phpunit/issues/4983

When researching this I stumbled into the process isolation feature request at https://github.com/sebastianbergmann/phpunit/issues/1351 which it turns out originates here :)

mondrake’s picture

Thanks a lot for opening the upstream issue @longwave.

mondrake’s picture

Assigned: Unassigned » mondrake

On this.

mondrake’s picture

Assigned: mondrake » Unassigned
Status: Needs work » Needs review

Fixed #16 and #18. Moved the onNotSuccessfulTest() implementation to a trait, see #3285270-3: Allow core test to only skip tests when a fragile part failed why.

mondrake’s picture

Status: Needs review » Needs work
mondrake’s picture

Status: Needs work » Needs review
lendude’s picture

+1 for moving this to a trait, which would also allow something like Drupal Test Traits to use this.

mondrake’s picture

Thinking again, the possible issue with this approach is that we will not increase the PHPunit assertions count - maybe we could, but only on failure, if we use Assert::fail() instead of just throwing the exception, but again we would lose the stack trace IIIC. And we may end up with successful tests reported as 'risky' if only WebAssert assertions are part of a test.

longwave’s picture

Re #31 not sure I follow, we already have this in BTB::setUp():

    // Ensure that the test is not marked as risky because of no assertions. In
    // PHPUnit 6 tests that only make assertions using $this->assertSession()
    // can be marked as risky.
    $this->addToAssertionCount(1);
mondrake’s picture

Ah, cool then I think #2805849: WebAssert does not track assertions, Test marked as risky no longer stands. addToAssertionCount has been marked @internal and will be final in PHPUnit 10, but still as long as we do not want to override it should be ok. Thanks and sorry for the noise.

longwave’s picture

Status: Needs review » Needs work

Not sure we are going to get any help from upstream here, so to me this looks good now, except some bikeshedding around the trait name :)

mondrake’s picture

Those names would be Mink specific though, and we will have another headache with #3285270: Allow core test to only skip tests when a fragile part failed which is up for something different still out of that same callback.

More general name? or a trait that somehow calls other traits?

longwave’s picture

Should the trait implement the method directly, or should it just provide something like rethrowMinkExceptions() (which would not throw anything in the default case), then it is up to BTB or other implementers to add the trait and implement onNotSuccessfulTest() themselves?

  /**
   * {@inheritdoc}
   */
  protected function onNotSuccessfulTest(\Throwable $t): void {
    $this->skipFragileTests($t);
    $this->rethrowMinkExceptions($t);
    parent::onNotSuccessfulTest($t);
  }

Maybe PHPUnit 10's event system will make this cleaner? :)

mondrake’s picture

Status: Needs work » Needs review

How about something like this?

longwave’s picture

Not sure this is the right way, the trait doesn't really do anything?

mondrake’s picture

It's there as a structural element, to avoid we do not proliferate alternative implementations of the callback.

In #3285270: Allow core test to only skip tests when a fragile part failed we can make it 'do something' as the skipping would be applicable to all test suites. Then non-BTB test suites can implement an empty unsuccessfulTestHandler() in the base class.

longwave’s picture

Status: Needs review » Reviewed & tested by the community

Oh I see. I think that makes sense but I do wonder if this should be left for that issue, but I guess it can be added here to save refactoring there.

Let's see what core committers think.

alexpott’s picture

Status: Reviewed & tested by the community » Needs work

Added a comment to the MR - not sure why we have the complicated trait architecture here. I also think we should push harder upstream. https://github.com/sebastianbergmann/phpunit/issues/4983#issuecomment-11...

mondrake’s picture

Status: Needs work » Reviewed & tested by the community

Please see #39. If rationale there is not enough, happy to revisit.

mondrake’s picture

Re #41,

I also think we should push harder upstream.

Upstream is a no-no. https://github.com/sebastianbergmann/phpunit/issues/4983#issuecomment-12...

alexpott’s picture

So given the comment from the PHPUnit maintainer we can go ahead here but also for me the comment from the maintainer is problematic for the current approach. We're reaching in to the internals of PHPUnit and setting \PHPUnit\Framework\Exception::$serializableTrace - this fixes what PHPUnit reports but if there is any later handling that relies on \PHPUnit\Framework\Exception::getSerializableTrace and \PHPUnit\Framework\Exception::getTrace actually matching - well they will not. I remain unconvinced that the benefits here outweigh the possible "what's going on - how come the trace and the serialized trace??!?!" problem. I think we just have to live the PHPUnit behaviour.

alexpott’s picture

And FWIW I think the parts where we are already converting to a PHPUnit exception are incorrect for the reason in #44.

mondrake’s picture

I was also thinking - instead of banging our heads against PHPUnit, shall we try Mink instead? I.e. make it possible for WebAssert to specify a callback to manage the assertion, and implement it with PHPUnit's assertTrue(). And only if no callback specified, fall back to the private behaviour

    /**
     * Asserts a condition.
     *
     * @param bool   $condition
     * @param string $message   Failure message
     *
     * @throws ExpectationException when the condition is not fulfilled
     */
    private function assert($condition, $message)
    {
        if ($condition) {
            return;
        }

        throw new ExpectationException($message, $this->session->getDriver());
    }

?

alexpott’s picture

@mondrake no harm in giving that a go.

mondrake’s picture

Filed https://github.com/minkphp/Mink/issues/835 upstream, when I find time I will try a PR.

catch’s picture

Status: Reviewed & tested by the community » Needs review

Sounds like we should hold off here.

mondrake’s picture

So, also Mink is pushing back hinting at a solution that is what the MR actually implements here, more or less, https://github.com/minkphp/Mink/issues/835#issuecomment-1260726225

Back to square one...

mondrake’s picture

StatusFileSize
new1.76 KB

I think in the end the solution here is the correct one, but the fact that uses reflection to push the Mink exception trace into PHPUnit internals which is likely not sustainable.

The problem is a PHPUnit's one though, not Drupal's.

So I looked into trying to fix the exception chaining trace issue in PHPUnit AssertionFailedError. With the patch attached here applied to PHPUnit, the MR here applied to Drupal, and NodeTitleTest broken like in #14, now the PHPUnit output will show

PHPUnit 9.5.24 #StandWithUkraine

Runtime:       PHP 8.1.10
Configuration: /home/runner/work/d8-unit/d8-unit/core/phpunit.xml.dist

Testing Drupal\Tests\node\Functional\NodeTitleTest
F                                                                   1 / 1 (100%)

Time: 00:06.500, Memory: 10.00 MB

There was 1 failure:

1) Drupal\Tests\node\Functional\NodeTitleTest::testNodeTitle
Title found

/home/runner/work/d8-unit/d8-unit/core/tests/Drupal/Tests/BrowserTestBase.php:441
/home/runner/work/d8-unit/d8-unit/core/tests/Drupal/Tests/Traits/UnsuccessfulTestHandlerTrait.php:16
/home/runner/work/d8-unit/d8-unit/vendor/phpunit/phpunit/src/Framework/TestResult.php:726

Caused by Behat\Mink\Exception\ExpectationException: Title found
/home/runner/work/d8-unit/d8-unit/core/tests/Drupal/Tests/WebAssert.php:538
/home/runner/work/d8-unit/d8-unit/core/tests/Drupal/Tests/WebAssert.php:281
/home/runner/work/d8-unit/d8-unit/core/modules/node/tests/src/Functional/NodeTitleTest.php:94
/home/runner/work/d8-unit/d8-unit/vendor/phpunit/phpunit/src/Framework/TestResult.php:726

FAILURES!
Tests: 1, Assertions: 17, Failures: 1.
mondrake’s picture

Any feedback before I start the quest to get the change into PHPUnit?

mondrake’s picture

Filed the https://github.com/sebastianbergmann/phpunit/issues/5068 issue upstream, will follow with a PR based on #51.

mondrake’s picture

mondrake’s picture

Assigned: Unassigned » mondrake
Status: Needs review » Needs work

PHPUnit upstream rejected to implement #5069 and will not work on #5068, so we're on our own again. I will try something else soon.

mondrake’s picture

Assigned: mondrake » Unassigned
Status: Needs work » Needs review

This is passing the parent stack trace as part of the AssertionFailedError message, and avoid exception chaining that is broken in PHPUnit for tests running in isolation.

mondrake’s picture

Assigned: Unassigned » mondrake
Status: Needs review » Needs work
mondrake’s picture

For reference, with the patch here and a broken NoteTitleTest like in #14, we get

Run vendor/bin/phpunit -v -c core --color=always core/modules/node/tests/src/Functional/NodeTitleTest.php
  
PHPUnit 9.5.24 #StandWithUkraine

Runtime:       PHP 8.1.11
Configuration: /home/runner/work/d8-unit/d8-unit/core/phpunit.xml.dist

Testing Drupal\Tests\node\Functional\NodeTitleTest
F                                                                   1 / 1 (100%)

Time: 00:12.737, Memory: 10.00 MB

There was 1 failure:

1) Drupal\Tests\node\Functional\NodeTitleTest::testNodeTitle
Title found
Caused by Behat\Mink\Exception\ExpectationException
/home/runner/work/d8-unit/d8-unit/core/tests/Drupal/Tests/WebAssert.php:538
/home/runner/work/d8-unit/d8-unit/core/tests/Drupal/Tests/WebAssert.php:281
/home/runner/work/d8-unit/d8-unit/core/modules/node/tests/src/Functional/NodeTitleTest.php:93
/home/runner/work/d8-unit/d8-unit/vendor/phpunit/phpunit/src/Framework/TestResult.php:726

/home/runner/work/d8-unit/d8-unit/core/tests/Drupal/Tests/BrowserTestBase.php:444
/home/runner/work/d8-unit/d8-unit/core/tests/Drupal/Tests/Traits/UnsuccessfulTestHandlerTrait.php:16
/home/runner/work/d8-unit/d8-unit/vendor/phpunit/phpunit/src/Framework/TestResult.php:726

FAILURES!
Tests: 1, Assertions: 17, Failures: 1.

Working on @alexpott's feedback.

mondrake’s picture

Assigned: mondrake » Unassigned
Status: Needs work » Needs review

Maybe this is just simpler?

mondrake’s picture

fixed

mondrake’s picture

@alexpott we probably do not need a trait then, is that OK?

mondrake’s picture

So, here changed to a trait that only manages the conversion of the Mink exception to a PHPUnit failure - that can be reused elsewhere, and left the onNotSuccessfulTest() implementation in BrowserTestBase.

jonathanshaw’s picture

An alternative to the trait approach might be to create a new error class for the purpose, that's what I did as a solution for this Mink problem in a similar context: https://github.com/jonathanjfshaw/phpunitbehat/pull/7/files#diff-194e3b5...

Functionally a very similar result, whether it's more or less elegant I'm not sure.

alexpott’s picture

@mondrake what do you think about #63? I like it.

mondrake’s picture

I think @longwave mentioned trying that #13 and #14, we'd have to extend AssertionFailedError here which would throw an error via SymfonyDebugClassloader since that's @internal.

But yes, maybe we no longer need a trait at this point of the MR, we could just have an abstract class with a static method to convert the Mink exception to a failure.

Thoughts?

smustgrave’s picture

Status: Needs review » Needs work
Issue tags: +Needs Review Queue Initiative

This issue is being reviewed by the kind folks in Slack, #need-reveiw-queue. We are working to keep the size of Needs Review queue [2700+ issues] to around 400 (1 month or less), following Review a patch or merge require as a guide.

Wonder if the MR could be updated to 10.1.x please moving to NW just for that.

The changes that are there look good to me but see in #65 there may be some more discussion to be had?

mondrake’s picture

Status: Needs work » Needs review

Yes, that's why this issue was in 'needs review'. Changing to 'needs work' does not help here since we do not know what work needs to be done.

smustgrave’s picture

If you see the comment though I moved to NW for the MR to be updated as this has missed the 10.0.x mark.

mondrake’s picture

Yes, I will be happy to do that when I get feedback on #65. I have concerns that bumping the branch for its own sake is not very productive.

smustgrave’s picture

Gotcha! Sounds good thanks.

mondrake’s picture

Bumped to 10.1.x and refactored to remove the need of the trait. We can't do #63 unless we break a PHPUnit @internal exception which is not good for future compatibility.

jonathanshaw’s picture

Status: Needs review » Reviewed & tested by the community

I've been an advocate of the trait or abstract class approach so that Drupal Test traits could reuse logic. But looking at the final shape if the MR, I think that consideration is very minor and should not delay this good improvement here.

mondrake’s picture

rebased

mondrake’s picture

FTR, in PHPUnit 10.1 this might be addressed via https://github.com/sebastianbergmann/phpunit/issues/5201

alexpott’s picture

@mondrake after all the previous discussion https://github.com/sebastianbergmann/phpunit/issues/5201 is quite amusing.

alexpott’s picture

I think given this won't work on PHPUnit 10 we consider waiting until we update to PHP 10.1 and can use the new capabilities.

mondrake’s picture

Status: Reviewed & tested by the community » Postponed

quite amusing

agree :)

waiting until we update to PHPUnit 10.1 and can use the new capabilities

agree... this is an improvement but not strictly necessary. Can wait.

mondrake’s picture

Issue tags: +PHPUnit 10
mondrake’s picture

Version: 10.0.x-dev » 11.x-dev
mondrake’s picture