Skip to content

[PHPUnit60] Count assertions outside the test method body in AddDoesNotPerformAssertionToNonAssertingTestRector - #822

Closed
bbrala wants to merge 2 commits into
rectorphp:mainfrom
bbrala:fix-does-not-perform-assertions-parent-and-deprecation
Closed

bbrala wants to merge 2 commits into
rectorphp:mainfrom
bbrala:fix-does-not-perform-assertions-parent-and-deprecation

Conversation

@bbrala

@bbrala bbrala commented Nov 14, 2026 •

Copy link
Copy Markdown
Contributor

Fixes rectorphp/rector#9965.

AddDoesNotPerformAssertionToNonAssertingTestRector only looks at assertions reachable from the test method body, but PHPUnit counts assertions from more places. It added #[DoesNotPerformAssertions] to three kinds of tests that do assert, which then fail with failOnRisky: "This test is not expected to perform assertions but performed N assertions".

1. expectUserDeprecationMessage() / expectUserDeprecationMessageMatches()

PHPUnit counts every expected deprecation as an assertion (TestCase::verifyDeprecationExpectations() increments numberOfAssertionsPerformed), but neither method was in AssertCallAnalyzer::ASSERT_METHOD_NAME_PREFIXES. Added the expectUserDeprecationMessage prefix, which covers both. Of the other expect*() methods in PHPUnit's TestCase, only the exception expectations add to the assertion count, and those were already covered.

2. parent::testFoo() where the parent asserts

resolveClassMethodFromCall() resolves a static call through the type of $call->class. For parent, NameTypeResolver::resolveParent() returns a union of all ancestors (direct parent, TestCase, Assert...), not a TypeWithClassName, so the parent method was never inspected. parent is now resolved to the direct parent class through the scope's class reflection. self/static already resolve to a single object type and are unchanged.

This also needed a change to the containsAssertCall() cache key: it was md5(prettyPrint($classMethod)), so two methods with the same body in different classes (e.g. parent::testSomething(); with an asserting and a non-asserting parent) shared one cached result. The key now includes the class name when the scope has one.

3. Assertions in lifecycle methods

Assertions in setUp(), tearDown(), assertPreConditions(), assertPostConditions() and in methods marked #[Before], #[After], #[PreCondition], #[PostCondition] (or the matching annotations) count toward every test of the class. The new LifecycleMethodAssertAnalyzer walks the class hierarchy up to TestCase and runs containsAssertCall() on those methods; the rule skips the class's tests when one of them asserts. For the named hooks only the most derived override counts (PHPUnit calls that one; parents are reached through parent::, which case 2 now follows). The result is cached per class.

Tests

Fixtures that fail without the change:

  • skip_expect_user_deprecation_message
  • skip_parent_test_call_with_assert
  • skip_asserting_set_up, skip_asserting_tear_down_in_parent, skip_post_conditions_parent_call, skip_asserting_own_post_conditions, skip_asserting_before_attribute, skip_asserting_after_annotation

Regression guards that still get the attribute:

  • parent_test_call_without_assert (also covers the cache collision)
  • non_asserting_set_up

Full suite passes (704 tests), plus ECS, class-leak and Rector.

Context

Found running the composer-based PHPUnit set on Drupal core (https://www.drupal.org/i/3638148), where 68 tests became risky. Running the rule over core's test directories:

methods getting the attribute
main 83
with 1 + 2 43
with 1 + 2 + 3 3

Not covered here: hooks declared in traits, and helpers that call addToAssertionCount() (Drupal's drupalGet() does), which is why one of the remaining three still turns risky on core.

…onMessage() as assert in AddDoesNotPerformAssertionToNonAssertingTestRector (rectorphp/rector#9965)
bbrala added a commit to palantirnet/drupal-rector that referenced this pull request Nov 14, 2026
…in Drupal sets (#453)

It adds #[DoesNotPerformAssertions] to Drupal tests that do assert: in
PHPUnit lifecycle methods such as setUp(), tearDown() or
assertPostConditions() of a base class, through parent::testX() calls,
via expectUserDeprecationMessage(), or in helpers it can't resolve. With
failOnRisky those tests fail. rectorphp/rector-phpunit#822 fixes most of
these (rectorphp/rector#9965), but not unresolvable helpers, so the skip
stays.

https://www.drupal.org/i/3638148
@bbrala bbrala changed the title [PHPUnit60] Follow parent::test() calls and count expectUserDeprecationMessage() as assert in AddDoesNotPerformAssertionToNonAssertingTestRector [PHPUnit60] Count assertions outside the test method body in AddDoesNotPerformAssertionToNonAssertingTestRector Nov 14, 2026
return null;
}

return $this->astResolver->resolveClassMethod($className, $methodName);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is very costly apparoch, that should be used only as last resort. It pases whole file from scratch.

I added bench here to surface it. This PR make this rules run ~16 slower, see #824

Can you split this PR into multiple ones, first without AST resolver? Ideally with a bench script similar to 824
That way we can merge the fast paths now.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Was about to do it, wplit into three. Researcher came back with the issue being the parent cache key mostly. Since parent might not be the same.

Do you still want me to have claude split mine up 😉

@bbrala

bbrala commented Nov 14, 2026

Copy link
Copy Markdown
Contributor Author

Split up, thanks for #825 and the bench:

Most of the original 16x came from the cache key and from re-parsing the file being processed; both are gone. I'll close this one.

@bbrala bbrala closed this Nov 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

2 participants