Skip to content

fix: report private members of the bound test case in pest closures - #20

Open
MrPunyapal wants to merge 1 commit into
5.xfrom
fix/bound-test-case-private-member-scope
Open

MrPunyapal wants to merge 1 commit into
5.xfrom
fix/bound-test-case-private-member-scope

Conversation

@MrPunyapal

Copy link
Copy Markdown
Member

The problem

uses(SomeTestCase::class) binds that class to the generated test case, so Pest generates a class that extends it. A closure passed to it() is bound to the generated class, not to SomeTestCase, so PHP private scope rules put the private members of SomeTestCase out of reach:

class MyTestCase extends TestCase
{
    private function helper(): string { return 'helper'; }
    private const NAME = 'name';
}

uses(MyTestCase::class);

it('...', function (): void {
    $this->helper();    // Call to private method MyTestCase::helper() from scope P\Tests\Feature\ExampleTest
    $x = $this::NAME;   // Undefined constant P\Tests\Feature\ExampleTest::NAME
});

pest-plugin-phpstan reports nothing for either. pestphp/pest#1945 fixed the opposite case, where members of a trait bound through uses() were reported even though they run fine.

Why

PHPStan registers the closure bind scope from getObjectClassNames() of the $this type, in enterAnonymousFunction:

$scopeToPass = $scopeToPass
    ->assignVariable('this', $closureThisType, ...)
    ->withClosureBindScopeClasses($closureThisType->getObjectClassNames());

TestClosureThisTypeExtension returns ObjectType(MyTestCase::class), so the bound class is registered as the closure's own scope. MutatingScope::canAccessClassMember() then grants a private member when its declaring class matches a bind scope class, so PHPStan stays silent.

The generated class only extends MyTestCase, so the correct bind scope would be a strict subclass. That class only exists once Pest has generated it at run time. Returning nothing from getObjectClassNames() does report the private members, but it reports protected ones too, and those do run fine.

The change

BoundTestCasePrivateMemberRule reports the private members PHPStan misses. It fires when the declaring class is in $thisType->getObjectClassNames(), which is exactly the set PHPStan treats as the closure's own scope, so nothing is reported twice.

case reported by
private member of the bound test case, including PHPUnit\Framework\TestCase::runTest() this rule
private member inherited from a parent class PHPStan, unchanged
private member of a trait bound through uses() neither, it runs fine
protected or public member neither
private member of another object PHPStan, unchanged
private member inside a closure declared in a class neither, that closure keeps the class as its scope

Identifiers are PHPStan's own, method.private, staticMethod.private and classConstant.private, because these are the diagnostics PHPStan should have produced here, and a @phpstan-ignore method.private a user already has keeps working.

rector.php gains a skip for tests/Type/Fixtures/PrivateMembers, alongside the existing fixture skips. Dead code detection cannot see the private members being called from analysed fixtures, and LocallyCalledStaticMethodToNonStaticRector fights the static case, so the directory is skipped rather than working around either.

Tests

tests/Rules/BoundTestCasePrivateMemberRuleTest.php, four tests. Every expression this rule reports was also run against Pest and fails at run time with the matching message, four on the bound test case and one on the default test case.

Each part of the change is covered by a test that fails without it:

change made to the rule result
none 4 of 4 pass
isInClass() guard removed a closure declared inside a class gets reported, 3 of 4
$this receiver check removed another object's private member is reported twice, 3 of 4
declaring class in getObjectClassNames() check removed inherited and trait members are reported, 3 of 4
isPrivate() check removed protected and public members are reported, 2 of 4
Identifier name check removed a dynamic member name crashes the rule, 3 of 4
isInAnonymousFunction() removed 4 of 4, redundant with the guards above
TestCase supertype check removed 4 of 4, nothing types $this as a non test case in a file level closure today

Checks

  • pest → 524 passed, 640 assertions
  • phpstan analyse → 0 errors
  • rector --dry-run → 0 changed files
  • pint --test → passed for every file in this PR. Pint also reports 22 pre-existing files on 5.x unchanged, all line_ending from a CRLF checkout, not touched here.

Not covered

Private properties of the bound test case. universalObjectCratesClasses covers PHPUnit\Framework\TestCase including subclasses, so any property access on $this is allowed and the type degrades to mixed. Reporting there is correct at run time, but it is a wider behaviour change than this PR takes on.

A private member of a trait used by the bound test case is also still silent. Its declaring class is the trait, so this rule does not reach it, and PHPStan does not report trait privates at all. Run time fails when that trait sits on the bound class rather than the generated one.

Pest binds a test closure to the generated test case, which only extends
the class given to uses(). PHP private scope rules therefore put the
private members of that class out of reach, and the plugin reports none
of them.

PHPStan registers the closure bind scope from getObjectClassNames() of the
$this type, so the bound class counts as the closure's own scope and
canAccessClassMember() lets its private members through. The correct
scope would be a strict subclass, which does not exist during analysis,
so this adds a rule that reports exactly the set PHPStan misses.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant