From d1452be886d669257bcfc3c92f532e035277bceb Mon Sep 17 00:00:00 2001 From: ragulka Date: Mon, 5 Oct 2026 15:39:52 +0300 Subject: [PATCH 1/3] feat: type a bare test() call as the bound test case test() without arguments returns a proxy to the running test case, but PHPStan only saw the declared HigherOrderTapProxy|TestCall, so method calls through it were unchecked. Resolve it the same way $this is resolved in test closures. Files no binding covers keep the declared type, so helper files outside a Pest.php binding report nothing new. Co-Authored-By: Claude Opus 5.5 --- extension.neon | 5 +++ src/Type/Pest/PestTestCaseType.php | 10 +++++ .../Pest/TestFunctionReturnTypeExtension.php | 36 ++++++++++++++++ .../Feature/test-function-custom-testcase.php | 41 +++++++++++++++++++ tests/Type/CustomTestCaseTest.php | 6 +++ tests/Type/ExpectTypeTest.php | 6 +++ tests/Type/data/test-function.php | 14 +++++++ 7 files changed, 118 insertions(+) create mode 100644 src/Type/Pest/TestFunctionReturnTypeExtension.php create mode 100644 tests/Fixtures/CustomTestCaseInference/Feature/test-function-custom-testcase.php create mode 100644 tests/Type/data/test-function.php diff --git a/extension.neon b/extension.neon index ac7f226..76ce936 100644 --- a/extension.neon +++ b/extension.neon @@ -33,6 +33,11 @@ services: tags: - phpstan.broker.dynamicFunctionReturnTypeExtension + - + class: Pest\PHPStan\Type\Pest\TestFunctionReturnTypeExtension + tags: + - phpstan.broker.dynamicFunctionReturnTypeExtension + - class: Pest\PHPStan\Type\Pest\ExpectationMethodReturnTypeExtension tags: diff --git a/src/Type/Pest/PestTestCaseType.php b/src/Type/Pest/PestTestCaseType.php index 1da0108..2971d87 100644 --- a/src/Type/Pest/PestTestCaseType.php +++ b/src/Type/Pest/PestTestCaseType.php @@ -19,6 +19,12 @@ public function __construct( public function resolve(string $filePath): Type { + return $this->resolveIfBound($filePath) ?? new ObjectType(TestCase::class); + } + + public function resolveIfBound(string $filePath): ?Type + { + // @note: returns null when no binding covers the file, so callers can tell an unbound file from one bound to PHPUnit's TestCase. [$classNames, $traitNames] = $this->partition( $this->pestConfigReader->resolveFileBindings($filePath), ); @@ -31,6 +37,10 @@ public function resolve(string $filePath): Type $traitNames = array_values(array_unique([...$directoryTraitNames, ...$traitNames])); } + if ($classNames === [] && $traitNames === []) { + return null; + } + if ($classNames === []) { $classNames[] = TestCase::class; } diff --git a/src/Type/Pest/TestFunctionReturnTypeExtension.php b/src/Type/Pest/TestFunctionReturnTypeExtension.php new file mode 100644 index 0000000..f9a2362 --- /dev/null +++ b/src/Type/Pest/TestFunctionReturnTypeExtension.php @@ -0,0 +1,36 @@ +getName() === 'test'; + } + + public function getTypeFromFunctionCall( + FunctionReflection $functionReflection, + FuncCall $functionCall, + Scope $scope + ): ?Type { + if ($functionCall->getArgs() !== []) { + return null; + } + + // @note: a bare test() proxies the running test case, so it gets the same type as $this in a test closure; an unbound file keeps the declared type. + return $this->pestTestCaseType->resolveIfBound($scope->getFile()); + } +} diff --git a/tests/Fixtures/CustomTestCaseInference/Feature/test-function-custom-testcase.php b/tests/Fixtures/CustomTestCaseInference/Feature/test-function-custom-testcase.php new file mode 100644 index 0000000..1de0d6c --- /dev/null +++ b/tests/Fixtures/CustomTestCaseInference/Feature/test-function-custom-testcase.php @@ -0,0 +1,41 @@ +createHelper()); + }); +} + +function testTestCallWithDescriptionIsUnchanged(): void +{ + assertType(TestCall::class, test('has a description')); +} diff --git a/tests/Type/CustomTestCaseTest.php b/tests/Type/CustomTestCaseTest.php index a13648b..2824a9e 100644 --- a/tests/Type/CustomTestCaseTest.php +++ b/tests/Type/CustomTestCaseTest.php @@ -11,6 +11,12 @@ yield from CustomTestCaseTestCase::gatherAssertTypes(__DIR__.'/../Fixtures/CustomTestCaseInference/Feature/test-with-closures-custom-testcase.php'); }); +test('a bare test() call types as the bound custom testcase', function (string $assertType, string $file, mixed ...$args): void { + $this->assertFileAsserts($assertType, $file, ...$args); +})->with(function (): Iterator { + yield from CustomTestCaseTestCase::gatherAssertTypes(__DIR__.'/../Fixtures/CustomTestCaseInference/Feature/test-function-custom-testcase.php'); +}); + test('custom testcase closure types when a class and a trait are bound', function (string $assertType, string $file, mixed ...$args): void { $this->assertFileAsserts($assertType, $file, ...$args); })->with(function (): Iterator { diff --git a/tests/Type/ExpectTypeTest.php b/tests/Type/ExpectTypeTest.php index ccb4808..bd47d2f 100644 --- a/tests/Type/ExpectTypeTest.php +++ b/tests/Type/ExpectTypeTest.php @@ -16,6 +16,12 @@ yield from TestCase::gatherAssertTypes(__DIR__.'/data/test-closures.php'); }); +test('bare test() call types', function (string $assertType, string $file, mixed ...$args): void { + $this->assertFileAsserts($assertType, $file, ...$args); +})->with(function (): Iterator { + yield from TestCase::gatherAssertTypes(__DIR__.'/data/test-function.php'); +}); + test('with closure types', function (string $assertType, string $file, mixed ...$args): void { $this->assertFileAsserts($assertType, $file, ...$args); })->with(function (): Iterator { diff --git a/tests/Type/data/test-function.php b/tests/Type/data/test-function.php new file mode 100644 index 0000000..e2a8e6d --- /dev/null +++ b/tests/Type/data/test-function.php @@ -0,0 +1,14 @@ + Date: Mon, 5 Oct 2026 15:40:54 +0300 Subject: [PATCH 2/3] feat: read beforeEach properties assigned through test() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hook property reader only collected `$this->name = …` assignments, so a hook written as `test()->name = …` left the property mixed. A bare test() proxies the running test case, so treat it as the same receiver. Reads through test() resolve via the existing property extension now that test() is typed as the test case. Co-Authored-By: Claude Opus 5.5 --- src/Type/Pest/PestHookPropertyReader.php | 24 ++++++++++--------- .../Feature/test-function-custom-testcase.php | 12 ++++++++++ tests/Type/data/test-hook-properties.php | 23 ++++++++++++++++++ 3 files changed, 48 insertions(+), 11 deletions(-) diff --git a/src/Type/Pest/PestHookPropertyReader.php b/src/Type/Pest/PestHookPropertyReader.php index 5b72139..7c3005f 100644 --- a/src/Type/Pest/PestHookPropertyReader.php +++ b/src/Type/Pest/PestHookPropertyReader.php @@ -288,17 +288,7 @@ private function extractPropertyAssignments(Closure|ArrowFunction $hook, array $ continue; } - if (! $var->var instanceof Variable) { - continue; - } - - $thisVariable = $var->var; - - if (! is_string($thisVariable->name)) { - continue; - } - - if ($thisVariable->name !== 'this') { + if (! $this->isTestCaseReference($var->var)) { continue; } @@ -330,6 +320,18 @@ private function extractPropertyAssignments(Closure|ArrowFunction $hook, array $ return $properties; } + private function isTestCaseReference(Expr $expr): bool + { + // @note: a bare test() proxies the running test case, so `test()->name = …` sets the same property as `$this->name = …`. + if ($expr instanceof FuncCall) { + return $expr->name instanceof Name + && $expr->name->toString() === 'test' + && $expr->getArgs() === []; + } + + return $expr instanceof Variable && $expr->name === 'this'; + } + /** * @param array $useMap * @return array diff --git a/tests/Fixtures/CustomTestCaseInference/Feature/test-function-custom-testcase.php b/tests/Fixtures/CustomTestCaseInference/Feature/test-function-custom-testcase.php index 1de0d6c..34843a4 100644 --- a/tests/Fixtures/CustomTestCaseInference/Feature/test-function-custom-testcase.php +++ b/tests/Fixtures/CustomTestCaseInference/Feature/test-function-custom-testcase.php @@ -6,6 +6,7 @@ use Pest\PendingCalls\TestCall; use Tests\Type\Fixtures\CustomTestCase; +use Tests\Type\Fixtures\Post; use function PHPStan\Testing\assertType; @@ -35,6 +36,17 @@ function testMethodCallThroughBareTestCall(): void }); } +function testHookPropertyReadThroughBareTestCall(): void +{ + beforeEach(function (): void { + test()->post = new Post; + }); + + it('resolves a hook property read through a bare test()', function (): void { + assertType(Post::class, test()->post); + }); +} + function testTestCallWithDescriptionIsUnchanged(): void { assertType(TestCall::class, test('has a description')); diff --git a/tests/Type/data/test-hook-properties.php b/tests/Type/data/test-hook-properties.php index 3ed3f56..c0b6e69 100644 --- a/tests/Type/data/test-hook-properties.php +++ b/tests/Type/data/test-hook-properties.php @@ -306,3 +306,26 @@ function testBeforeEachMutuallyReferentialProperties(): void assertType('mixed', $this->cycleFirst); }); } + +function testBeforeEachAssignmentThroughTestFunction(): void +{ + beforeEach(function (): void { + test()->viaTestFunction = new Post; + }); + + it('resolves a property a hook sets through test()', function (): void { + assertType(Post::class, $this->viaTestFunction); + }); +} + +function testBeforeEachAssignmentThroughTestFunctionWithLocalVariable(): void +{ + beforeEach(function (): void { + $author = new Author; + test()->viaTestFunctionLocal = $author; + }); + + it('resolves a local variable a hook sets through test()', function (): void { + assertType(Author::class, $this->viaTestFunctionLocal); + }); +} From 9ac88da5717554e307ddc427de15924b561aee31 Mon Sep 17 00:00:00 2001 From: ragulka Date: Wed, 7 Oct 2026 10:01:01 +0300 Subject: [PATCH 3/3] fix: type a bare test() only where $this is the test case A bare test() returns the running test case only while a test runs. In a helper function the type depends on the caller, so it now keeps the declared type. Inside test, hook and describe closures, and in closures nested in them, test() gets the same type as $this. Co-Authored-By: Claude Opus 5.5 --- .../Pest/TestFunctionReturnTypeExtension.php | 20 +++++++++++++++++-- .../Feature/test-function-custom-testcase.php | 20 +++++++++++++++++-- 2 files changed, 36 insertions(+), 4 deletions(-) diff --git a/src/Type/Pest/TestFunctionReturnTypeExtension.php b/src/Type/Pest/TestFunctionReturnTypeExtension.php index f9a2362..11337e9 100644 --- a/src/Type/Pest/TestFunctionReturnTypeExtension.php +++ b/src/Type/Pest/TestFunctionReturnTypeExtension.php @@ -8,7 +8,9 @@ use PHPStan\Analyser\Scope; use PHPStan\Reflection\FunctionReflection; use PHPStan\Type\DynamicFunctionReturnTypeExtension; +use PHPStan\Type\ObjectType; use PHPStan\Type\Type; +use PHPUnit\Framework\TestCase; final class TestFunctionReturnTypeExtension implements DynamicFunctionReturnTypeExtension { @@ -30,7 +32,21 @@ public function getTypeFromFunctionCall( return null; } - // @note: a bare test() proxies the running test case, so it gets the same type as $this in a test closure; an unbound file keeps the declared type. - return $this->pestTestCaseType->resolveIfBound($scope->getFile()); + if (! $this->pestTestCaseType->resolveIfBound($scope->getFile()) instanceof Type) { + return null; + } + + if (! $scope->hasVariableType('this')->yes()) { + return null; + } + + // @note: a bare test() proxies the running test case, so it only gets the test case type where $this is the test case; helper functions and unbound files keep the declared type. + $thisType = $scope->getVariableType('this'); + + if (! new ObjectType(TestCase::class)->isSuperTypeOf($thisType)->yes()) { + return null; + } + + return $thisType; } } diff --git a/tests/Fixtures/CustomTestCaseInference/Feature/test-function-custom-testcase.php b/tests/Fixtures/CustomTestCaseInference/Feature/test-function-custom-testcase.php index 34843a4..7242e56 100644 --- a/tests/Fixtures/CustomTestCaseInference/Feature/test-function-custom-testcase.php +++ b/tests/Fixtures/CustomTestCaseInference/Feature/test-function-custom-testcase.php @@ -24,9 +24,25 @@ function testBareTestCallInBeforeEach(): void }); } -function testBareTestCallInHelperFunction(): void +function testBareTestCallInHelperFunctionKeepsDeclaredType(): void { - assertType(CustomTestCase::class, test()); + assertType('Pest\\PendingCalls\\TestCall|Pest\\Support\\HigherOrderTapProxy', test()); +} + +function testBareTestCallInStaticClosureKeepsDeclaredType(): void +{ + it('keeps the declared type without $this', static function (): void { + assertType('Pest\\PendingCalls\\TestCall|Pest\\Support\\HigherOrderTapProxy', test()); + }); +} + +function testBareTestCallInNestedArrowFunction(): void +{ + it('types a bare test() inside a nested arrow function', function (): void { + $resolve = fn () => test(); + + assertType(CustomTestCase::class, $resolve()); + }); } function testMethodCallThroughBareTestCall(): void