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/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/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..11337e9 --- /dev/null +++ b/src/Type/Pest/TestFunctionReturnTypeExtension.php @@ -0,0 +1,52 @@ +getName() === 'test'; + } + + public function getTypeFromFunctionCall( + FunctionReflection $functionReflection, + FuncCall $functionCall, + Scope $scope + ): ?Type { + if ($functionCall->getArgs() !== []) { + return null; + } + + 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 new file mode 100644 index 0000000..7242e56 --- /dev/null +++ b/tests/Fixtures/CustomTestCaseInference/Feature/test-function-custom-testcase.php @@ -0,0 +1,69 @@ + test(); + + assertType(CustomTestCase::class, $resolve()); + }); +} + +function testMethodCallThroughBareTestCall(): void +{ + it('resolves methods through a bare test()', function (): void { + assertType('string', test()->createHelper()); + }); +} + +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/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 @@ +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); + }); +}