From d1452be886d669257bcfc3c92f532e035277bceb Mon Sep 17 00:00:00 2001 From: ragulka Date: Mon, 5 Oct 2026 15:39:52 +0300 Subject: [PATCH 1/4] 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/4] 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/4] 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 From bfdb8d884e32e3ae585da257b2fe8c5fa4d51b7d Mon Sep 17 00:00:00 2001 From: Punyapal Shah Date: Wed, 28 Oct 2026 23:32:55 +0530 Subject: [PATCH 4/4] fix: cover bare test calls in helpers and proxy methods --- extension.neon | 9 +++ src/Type/Pest/PestTestFunction.php | 57 +++++++++++++++++++ ...estCaseMemberVisibilityIgnoreExtension.php | 25 ++++---- .../Pest/TestFunctionProxyMethodExtension.php | 47 +++++++++++++++ .../Pest/TestFunctionReturnTypeExtension.php | 20 +++---- tests/Analysis/PestTestFunctionTest.php | 27 +++++++++ .../Feature/test-function-custom-testcase.php | 20 +++++-- .../Feature/test-function-custom-todo.php | 13 +++++ .../Feature/test-function-method-calls.php | 24 ++++++++ .../test-function-undefined-method.php | 10 ++++ .../trait-visibility-calls.php | 6 ++ .../ProtectedMethodCallCustomTestCaseTest.php | 15 +++++ tests/Type/CustomTestCaseTest.php | 1 + tests/Type/Fixtures/CustomTodoTestCase.php | 15 +++++ 14 files changed, 263 insertions(+), 26 deletions(-) create mode 100644 src/Type/Pest/PestTestFunction.php create mode 100644 src/Type/Pest/TestFunctionProxyMethodExtension.php create mode 100644 tests/Analysis/PestTestFunctionTest.php create mode 100644 tests/Fixtures/CustomTestCaseInference/Feature/test-function-custom-todo.php create mode 100644 tests/Fixtures/CustomTestCaseInference/Feature/test-function-method-calls.php create mode 100644 tests/Fixtures/CustomTestCaseInference/Feature/test-function-undefined-method.php create mode 100644 tests/Type/Fixtures/CustomTodoTestCase.php diff --git a/extension.neon b/extension.neon index f75177c..b01f8a7 100644 --- a/extension.neon +++ b/extension.neon @@ -46,6 +46,15 @@ services: tags: - phpstan.broker.dynamicFunctionReturnTypeExtension + - + class: Pest\PHPStan\Type\Pest\PestTestFunction + + - + class: Pest\PHPStan\Type\Pest\TestFunctionProxyMethodExtension + tags: + - phpstan.broker.expressionTypeResolverExtension + - phpstan.ignoreErrorExtension + - class: Pest\PHPStan\Type\Pest\ExpectationMethodReturnTypeExtension tags: diff --git a/src/Type/Pest/PestTestFunction.php b/src/Type/Pest/PestTestFunction.php new file mode 100644 index 0000000..5a9c806 --- /dev/null +++ b/src/Type/Pest/PestTestFunction.php @@ -0,0 +1,57 @@ +isPestFunction($function, 'test'); + } + + public function supportsTodoHelper(Scope $scope): bool + { + $name = new FullyQualified('todo'); + + return $this->reflectionProvider->hasFunction($name, $scope) + && $this->isPestFunction($this->reflectionProvider->getFunction($name, $scope), 'todo'); + } + + public function isBareCall(Expr $expression, Scope $scope): bool + { + return $expression instanceof FuncCall + && $expression->name instanceof Name + && $expression->getArgs() === [] + && $this->reflectionProvider->hasFunction($expression->name, $scope) + && $this->supports($this->reflectionProvider->getFunction($expression->name, $scope)); + } + + private function isPestFunction(FunctionReflection $function, string $name): bool + { + if ($function->getName() !== $name) { + return false; + } + + $filename = $function->getFileName(); + $testSuiteFilename = $this->reflectionProvider->getClass(TestSuite::class)->getFileName(); + + return $filename !== null + && $testSuiteFilename !== null + && str_replace('\\', '/', $filename) === str_replace('\\', '/', dirname($testSuiteFilename).'/Functions.php'); + } +} diff --git a/src/Type/Pest/TestCaseMemberVisibilityIgnoreExtension.php b/src/Type/Pest/TestCaseMemberVisibilityIgnoreExtension.php index f8606d4..50c3c22 100644 --- a/src/Type/Pest/TestCaseMemberVisibilityIgnoreExtension.php +++ b/src/Type/Pest/TestCaseMemberVisibilityIgnoreExtension.php @@ -26,6 +26,10 @@ final class TestCaseMemberVisibilityIgnoreExtension implements IgnoreErrorExtens private const array CONSTANT_IDENTIFIERS = ['classConstant.protected', 'classConstant.private']; + public function __construct( + private readonly PestTestFunction $testFunction, + ) {} + public function shouldIgnore(Error $error, Node $node, Scope $scope): bool { $identifier = $error->getIdentifier(); @@ -37,20 +41,19 @@ public function shouldIgnore(Error $error, Node $node, Scope $scope): bool default => null, }; - if (! $receiver instanceof Variable || $receiver->name !== 'this') { - return false; - } - - if (! $scope->isInAnonymousFunction()) { + if ($receiver instanceof Expr && $this->testFunction->isBareCall($receiver, $scope)) { + $thisType = $scope->getType($receiver); + } elseif ( + $receiver instanceof Variable + && $receiver->name === 'this' + && $scope->isInAnonymousFunction() + && $scope->hasVariableType('this')->yes() + ) { + $thisType = $scope->getVariableType('this'); + } else { return false; } - if (! $scope->hasVariableType('this')->yes()) { - return false; - } - - $thisType = $scope->getVariableType('this'); - if (! new ObjectType(TestCase::class)->isSuperTypeOf($thisType)->yes()) { return false; } diff --git a/src/Type/Pest/TestFunctionProxyMethodExtension.php b/src/Type/Pest/TestFunctionProxyMethodExtension.php new file mode 100644 index 0000000..40f9136 --- /dev/null +++ b/src/Type/Pest/TestFunctionProxyMethodExtension.php @@ -0,0 +1,47 @@ +isTodoCall($expr, $scope) ? new ObjectType(TestCall::class) : null; + } + + public function shouldIgnore(Error $error, Node $node, Scope $scope): bool + { + return $error->getIdentifier() === 'method.notFound' + && $this->isTodoCall($node, $scope); + } + + private function isTodoCall(Node $node, Scope $scope): bool + { + return $node instanceof MethodCall + && $node->name instanceof Identifier + && $node->name->toLowerString() === 'todo' + && $this->testFunction->isBareCall($node->var, $scope) + && $this->testCaseType->resolveIfBound($scope->getFile()) instanceof Type + && ! $scope->getType($node->var)->hasMethod('todo')->yes() + && $this->testFunction->supportsTodoHelper($scope); + } +} diff --git a/src/Type/Pest/TestFunctionReturnTypeExtension.php b/src/Type/Pest/TestFunctionReturnTypeExtension.php index 11337e9..3cbd806 100644 --- a/src/Type/Pest/TestFunctionReturnTypeExtension.php +++ b/src/Type/Pest/TestFunctionReturnTypeExtension.php @@ -16,11 +16,12 @@ final class TestFunctionReturnTypeExtension implements DynamicFunctionReturnType { public function __construct( private readonly PestTestCaseType $pestTestCaseType, + private readonly PestTestFunction $testFunction, ) {} public function isFunctionSupported(FunctionReflection $functionReflection): bool { - return $functionReflection->getName() === 'test'; + return $this->testFunction->supports($functionReflection); } public function getTypeFromFunctionCall( @@ -32,21 +33,20 @@ public function getTypeFromFunctionCall( return null; } - if (! $this->pestTestCaseType->resolveIfBound($scope->getFile()) instanceof Type) { - return null; - } + $boundType = $this->pestTestCaseType->resolveIfBound($scope->getFile()); - if (! $scope->hasVariableType('this')->yes()) { + if (! $boundType instanceof Type) { 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 ($scope->hasVariableType('this')->yes()) { + $thisType = $scope->getVariableType('this'); - if (! new ObjectType(TestCase::class)->isSuperTypeOf($thisType)->yes()) { - return null; + if (new ObjectType(TestCase::class)->isSuperTypeOf($thisType)->yes()) { + return $thisType; + } } - return $thisType; + return $boundType; } } diff --git a/tests/Analysis/PestTestFunctionTest.php b/tests/Analysis/PestTestFunctionTest.php new file mode 100644 index 0000000..b1b59b9 --- /dev/null +++ b/tests/Analysis/PestTestFunctionTest.php @@ -0,0 +1,27 @@ +getByType(ReflectionProvider::class); + $testFunction = new PestTestFunction($provider); + $pestFilename = $provider->getClass(TestSuite::class)->getFileName(); + + expect($pestFilename)->not->toBeNull(); + + $pest = $this->createStub(FunctionReflection::class); + $pest->method('getName')->willReturn('test'); + $pest->method('getFileName')->willReturn(dirname($pestFilename).'/Functions.php'); + + $custom = $this->createStub(FunctionReflection::class); + $custom->method('getName')->willReturn('test'); + $custom->method('getFileName')->willReturn(__FILE__); + + expect($testFunction->supports($pest))->toBeTrue() + ->and($testFunction->supports($custom))->toBeFalse(); +}); diff --git a/tests/Fixtures/CustomTestCaseInference/Feature/test-function-custom-testcase.php b/tests/Fixtures/CustomTestCaseInference/Feature/test-function-custom-testcase.php index 7242e56..9518d31 100644 --- a/tests/Fixtures/CustomTestCaseInference/Feature/test-function-custom-testcase.php +++ b/tests/Fixtures/CustomTestCaseInference/Feature/test-function-custom-testcase.php @@ -24,15 +24,18 @@ function testBareTestCallInBeforeEach(): void }); } -function testBareTestCallInHelperFunctionKeepsDeclaredType(): void +function testBareTestCallInHelperFunction(): void { - assertType('Pest\\PendingCalls\\TestCall|Pest\\Support\\HigherOrderTapProxy', test()); + assertType(CustomTestCase::class, test()); + assertType('string', test()->createHelper()); } -function testBareTestCallInStaticClosureKeepsDeclaredType(): void +function testBareTestCallInStaticCallback(): void { - it('keeps the declared type without $this', static function (): void { - assertType('Pest\\PendingCalls\\TestCall|Pest\\Support\\HigherOrderTapProxy', test()); + it('uses the bound type in static callbacks without $this', function (): void { + $resolve = static fn () => test(); + + assertType(CustomTestCase::class, $resolve()); }); } @@ -67,3 +70,10 @@ function testTestCallWithDescriptionIsUnchanged(): void { assertType(TestCall::class, test('has a description')); } + +function testTodoThroughBareTestCall(): void +{ + it('preserves the todo helper on the proxy', function (): void { + assertType(TestCall::class, test()->todo('implement this test')); + }); +} diff --git a/tests/Fixtures/CustomTestCaseInference/Feature/test-function-custom-todo.php b/tests/Fixtures/CustomTestCaseInference/Feature/test-function-custom-todo.php new file mode 100644 index 0000000..bb8a8ea --- /dev/null +++ b/tests/Fixtures/CustomTestCaseInference/Feature/test-function-custom-todo.php @@ -0,0 +1,13 @@ +todo()); +}); diff --git a/tests/Fixtures/CustomTestCaseInference/Feature/test-function-method-calls.php b/tests/Fixtures/CustomTestCaseInference/Feature/test-function-method-calls.php new file mode 100644 index 0000000..7b157eb --- /dev/null +++ b/tests/Fixtures/CustomTestCaseInference/Feature/test-function-method-calls.php @@ -0,0 +1,24 @@ +createMock(stdClass::class); + + return test()->createHelper(); +} + +it('uses protected methods from static callbacks', function (): void { + $resolve = static fn (): string => test()->createHelper(); + + $resolve(); +}); + +it('can use the todo helper through the proxy', function (): void { + test()->todo('implement this test')->group('pending'); +}); diff --git a/tests/Fixtures/CustomTestCaseInference/Feature/test-function-undefined-method.php b/tests/Fixtures/CustomTestCaseInference/Feature/test-function-undefined-method.php new file mode 100644 index 0000000..0fdd2b9 --- /dev/null +++ b/tests/Fixtures/CustomTestCaseInference/Feature/test-function-undefined-method.php @@ -0,0 +1,10 @@ +createHelpre(); +} diff --git a/tests/Fixtures/CustomTestCaseInference/TraitVisibility/trait-visibility-calls.php b/tests/Fixtures/CustomTestCaseInference/TraitVisibility/trait-visibility-calls.php index 167c003..b59e54e 100644 --- a/tests/Fixtures/CustomTestCaseInference/TraitVisibility/trait-visibility-calls.php +++ b/tests/Fixtures/CustomTestCaseInference/TraitVisibility/trait-visibility-calls.php @@ -26,3 +26,9 @@ $this->privateTraitMethod(); $this::privateStaticTraitMethod(); }); + +function helperUsingBoundTraitMethods(): void +{ + test()->protectedTraitMethod(); + test()->privateTraitMethod(); +} diff --git a/tests/Rules/ProtectedMethodCallCustomTestCaseTest.php b/tests/Rules/ProtectedMethodCallCustomTestCaseTest.php index 01ced9f..1439847 100644 --- a/tests/Rules/ProtectedMethodCallCustomTestCaseTest.php +++ b/tests/Rules/ProtectedMethodCallCustomTestCaseTest.php @@ -6,6 +6,7 @@ use PHPStan\Rules\Methods\CallMethodsRule; use Tests\RuleTestCase; +use Tests\Type\Fixtures\CustomTestCase; beforeAll(function (): void { RuleTestCase::$additionalConfigFiles = [ @@ -19,3 +20,17 @@ __DIR__.'/../Fixtures/CustomTestCaseInference/Feature/protected-method-calls-custom-testcase.php', ], []); }); + +test('bare test calls allow protected methods and proxy helpers', function (): void { + $this->analyse([ + __DIR__.'/../Fixtures/CustomTestCaseInference/Feature/test-function-method-calls.php', + ], []); +}); + +test('bare test calls still report undefined methods in helpers', function (): void { + $this->analyse([ + __DIR__.'/../Fixtures/CustomTestCaseInference/Feature/test-function-undefined-method.php', + ], [ + ['Call to an undefined method '.CustomTestCase::class.'::createHelpre().', 9], + ]); +}); diff --git a/tests/Type/CustomTestCaseTest.php b/tests/Type/CustomTestCaseTest.php index 2824a9e..013e2d5 100644 --- a/tests/Type/CustomTestCaseTest.php +++ b/tests/Type/CustomTestCaseTest.php @@ -15,6 +15,7 @@ $this->assertFileAsserts($assertType, $file, ...$args); })->with(function (): Iterator { yield from CustomTestCaseTestCase::gatherAssertTypes(__DIR__.'/../Fixtures/CustomTestCaseInference/Feature/test-function-custom-testcase.php'); + yield from CustomTestCaseTestCase::gatherAssertTypes(__DIR__.'/../Fixtures/CustomTestCaseInference/Feature/test-function-custom-todo.php'); }); test('custom testcase closure types when a class and a trait are bound', function (string $assertType, string $file, mixed ...$args): void { diff --git a/tests/Type/Fixtures/CustomTodoTestCase.php b/tests/Type/Fixtures/CustomTodoTestCase.php new file mode 100644 index 0000000..0fd747f --- /dev/null +++ b/tests/Type/Fixtures/CustomTodoTestCase.php @@ -0,0 +1,15 @@ +