Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions extension.neon
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
24 changes: 13 additions & 11 deletions src/Type/Pest/PestHookPropertyReader.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down Expand Up @@ -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<string, string> $useMap
* @return array<string, Expr>
Expand Down
10 changes: 10 additions & 0 deletions src/Type/Pest/PestTestCaseType.php
Original file line number Diff line number Diff line change
Expand Up @@ -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),
);
Expand All @@ -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;
}
Expand Down
52 changes: 52 additions & 0 deletions src/Type/Pest/TestFunctionReturnTypeExtension.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
<?php

declare(strict_types=1);

namespace Pest\PHPStan\Type\Pest;

use PhpParser\Node\Expr\FuncCall;
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
{
public function __construct(
private readonly PestTestCaseType $pestTestCaseType,
) {}

public function isFunctionSupported(FunctionReflection $functionReflection): bool
{
return $functionReflection->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;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
<?php

declare(strict_types=1);

namespace TestFunctionCustomTestCase;

use Pest\PendingCalls\TestCall;
use Tests\Type\Fixtures\CustomTestCase;
use Tests\Type\Fixtures\Post;

use function PHPStan\Testing\assertType;

function testBareTestCallInTestClosure(): void
{
it('types a bare test() as the bound test case', function (): void {
assertType(CustomTestCase::class, test());
});
}

function testBareTestCallInBeforeEach(): void
{
beforeEach(function (): void {
assertType(CustomTestCase::class, test());
});
}

function testBareTestCallInHelperFunctionKeepsDeclaredType(): void
{
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
{
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'));
}
6 changes: 6 additions & 0 deletions tests/Type/CustomTestCaseTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
6 changes: 6 additions & 0 deletions tests/Type/ExpectTypeTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
14 changes: 14 additions & 0 deletions tests/Type/data/test-function.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<?php

declare(strict_types=1);

namespace TestFunction;

use function PHPStan\Testing\assertType;

function testBareTestCallWithoutBindingKeepsDeclaredType(): void
{
it('keeps the declared type when no binding covers the file', function (): void {
assertType('Pest\PendingCalls\TestCall|Pest\Support\HigherOrderTapProxy', test());
});
}
23 changes: 23 additions & 0 deletions tests/Type/data/test-hook-properties.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
}