diff --git a/extension.neon b/extension.neon index 3395ec1..b4794a7 100644 --- a/extension.neon +++ b/extension.neon @@ -5,9 +5,7 @@ parameters: - markTestIncomplete - markTestSkipped stubFiles: - - stubs/MockBuilder.stub - stubs/MockObject.stub - - stubs/TestCase.stub services: - @@ -26,6 +24,14 @@ services: class: PHPStan\Type\PHPUnit\Assert\AssertStaticMethodTypeSpecifyingExtension tags: - phpstan.typeSpecifier.staticMethodTypeSpecifyingExtension + - + class: PHPStan\Type\PHPUnit\CreateMockDynamicReturnTypeExtension + tags: + - phpstan.broker.dynamicMethodReturnTypeExtension + - + class: PHPStan\Type\PHPUnit\GetMockBuilderDynamicReturnTypeExtension + tags: + - phpstan.broker.dynamicMethodReturnTypeExtension - class: PHPStan\Type\PHPUnit\MockBuilderDynamicReturnTypeExtension tags: diff --git a/phpstan.neon b/phpstan.neon index 9c4be9d..1138813 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -7,6 +7,7 @@ includes: parameters: excludes_analyse: - tests/*/data/* + treatPhpDocTypesAsCertain: false services: scopeIsInClass: diff --git a/src/Type/PHPUnit/CreateMockDynamicReturnTypeExtension.php b/src/Type/PHPUnit/CreateMockDynamicReturnTypeExtension.php new file mode 100644 index 0000000..3a03fea --- /dev/null +++ b/src/Type/PHPUnit/CreateMockDynamicReturnTypeExtension.php @@ -0,0 +1,70 @@ + 0, + 'createConfiguredMock' => 0, + 'createPartialMock' => 0, + 'createTestProxy' => 0, + 'getMockForAbstractClass' => 0, + 'getMockFromWsdl' => 1, + ]; + + public function getClass(): string + { + return 'PHPUnit\Framework\TestCase'; + } + + public function isMethodSupported(MethodReflection $methodReflection): bool + { + $name = $methodReflection->getName(); + return array_key_exists($methodReflection->getName(), $this->methods); + } + + public function getTypeFromMethodCall(MethodReflection $methodReflection, MethodCall $methodCall, Scope $scope): Type + { + $argumentIndex = $this->methods[$methodReflection->getName()]; + $parametersAcceptor = ParametersAcceptorSelector::selectSingle($methodReflection->getVariants()); + if (!isset($methodCall->args[$argumentIndex])) { + return $parametersAcceptor->getReturnType(); + } + $argType = $scope->getType($methodCall->args[$argumentIndex]->value); + + $types = []; + if ($argType instanceof ConstantStringType) { + $types[] = new ObjectType($argType->getValue()); + } + + if ($argType instanceof ConstantArrayType) { + $types = array_map(function (ConstantStringType $argType): ObjectType { + return new ObjectType($argType->getValue()); + }, $argType->getValueTypes()); + } + + if (count($types) === 0) { + return $parametersAcceptor->getReturnType(); + } + + return TypeCombinator::intersect( + $parametersAcceptor->getReturnType(), + ...$types + ); + } + +} diff --git a/src/Type/PHPUnit/GetMockBuilderDynamicReturnTypeExtension.php b/src/Type/PHPUnit/GetMockBuilderDynamicReturnTypeExtension.php new file mode 100644 index 0000000..5149767 --- /dev/null +++ b/src/Type/PHPUnit/GetMockBuilderDynamicReturnTypeExtension.php @@ -0,0 +1,57 @@ +getName() === 'getMockBuilder'; + } + + public function getTypeFromMethodCall(MethodReflection $methodReflection, MethodCall $methodCall, Scope $scope): Type + { + $parametersAcceptor = ParametersAcceptorSelector::selectSingle($methodReflection->getVariants()); + $mockBuilderType = $parametersAcceptor->getReturnType(); + if (count($methodCall->args) === 0) { + return $mockBuilderType; + } + if (!$mockBuilderType instanceof TypeWithClassName) { + throw new \PHPStan\ShouldNotHappenException(); + } + + $argType = $scope->getType($methodCall->args[0]->value); + if ($argType instanceof ConstantStringType) { + $class = $argType->getValue(); + + return new MockBuilderType($mockBuilderType, $class); + } + + if ($argType instanceof ConstantArrayType) { + $classes = array_map(function (ConstantStringType $argType): string { + return $argType->getValue(); + }, $argType->getValueTypes()); + + return new MockBuilderType($mockBuilderType, ...$classes); + } + + return $mockBuilderType; + } + +} diff --git a/src/Type/PHPUnit/MockBuilderDynamicReturnTypeExtension.php b/src/Type/PHPUnit/MockBuilderDynamicReturnTypeExtension.php index d6ae40d..37a1919 100644 --- a/src/Type/PHPUnit/MockBuilderDynamicReturnTypeExtension.php +++ b/src/Type/PHPUnit/MockBuilderDynamicReturnTypeExtension.php @@ -4,34 +4,72 @@ use PhpParser\Node\Expr\MethodCall; use PHPStan\Analyser\Scope; +use PHPStan\Broker\Broker; +use PHPStan\Reflection\BrokerAwareExtension; use PHPStan\Reflection\MethodReflection; +use PHPStan\Reflection\ParametersAcceptorSelector; +use PHPStan\Type\DynamicMethodReturnTypeExtension; +use PHPStan\Type\ObjectType; use PHPStan\Type\Type; -use PHPUnit\Framework\MockObject\MockBuilder; +use PHPStan\Type\TypeCombinator; +use PHPStan\Type\TypeWithClassName; -class MockBuilderDynamicReturnTypeExtension implements \PHPStan\Type\DynamicMethodReturnTypeExtension +class MockBuilderDynamicReturnTypeExtension implements DynamicMethodReturnTypeExtension, BrokerAwareExtension { + /** @var \PHPStan\Broker\Broker */ + private $broker; + + public function setBroker(Broker $broker): void + { + $this->broker = $broker; + } + public function getClass(): string { - return MockBuilder::class; + $testCase = $this->broker->getClass('PHPUnit\Framework\TestCase'); + $mockBuilderType = ParametersAcceptorSelector::selectSingle( + $testCase->getNativeMethod('getMockBuilder')->getVariants() + )->getReturnType(); + if (!$mockBuilderType instanceof TypeWithClassName) { + throw new \PHPStan\ShouldNotHappenException(); + } + + return $mockBuilderType->getClassName(); } public function isMethodSupported(MethodReflection $methodReflection): bool { - return !in_array( + return true; + } + + public function getTypeFromMethodCall(MethodReflection $methodReflection, MethodCall $methodCall, Scope $scope): Type + { + $calledOnType = $scope->getType($methodCall->var); + if (!in_array( $methodReflection->getName(), [ 'getMock', 'getMockForAbstractClass', - 'getMockForTrait', ], true - ); - } + )) { + return $calledOnType; + } - public function getTypeFromMethodCall(MethodReflection $methodReflection, MethodCall $methodCall, Scope $scope): Type - { - return $scope->getType($methodCall->var); + $parametersAcceptor = ParametersAcceptorSelector::selectSingle($methodReflection->getVariants()); + + if (!$calledOnType instanceof MockBuilderType) { + return $parametersAcceptor->getReturnType(); + } + $types = array_map(function (string $type): ObjectType { + return new ObjectType($type); + }, $calledOnType->getMockedClasses()); + + return TypeCombinator::intersect( + $parametersAcceptor->getReturnType(), + ...$types + ); } } diff --git a/src/Type/PHPUnit/MockBuilderType.php b/src/Type/PHPUnit/MockBuilderType.php new file mode 100644 index 0000000..d77c3b6 --- /dev/null +++ b/src/Type/PHPUnit/MockBuilderType.php @@ -0,0 +1,37 @@ + */ + private $mockedClasses; + + public function __construct( + TypeWithClassName $mockBuilderType, + string ...$mockedClasses + ) + { + parent::__construct($mockBuilderType->getClassName()); + $this->mockedClasses = $mockedClasses; + } + + /** + * @return array + */ + public function getMockedClasses(): array + { + return $this->mockedClasses; + } + + public function describe(VerbosityLevel $level): string + { + return sprintf('%s<%s>', parent::describe($level), implode('&', $this->mockedClasses)); + } + +} diff --git a/stubs/MockBuilder.stub b/stubs/MockBuilder.stub deleted file mode 100644 index 1aaf138..0000000 --- a/stubs/MockBuilder.stub +++ /dev/null @@ -1,29 +0,0 @@ - $type - */ - public function __construct(TestCase $testCase, $type) {} - - /** - * @phpstan-return MockObject&TMockedClass - */ - public function getMock() {} - - /** - * @phpstan-return MockObject&TMockedClass - */ - public function getMockForAbstractClass() {} - -} diff --git a/stubs/TestCase.stub b/stubs/TestCase.stub deleted file mode 100644 index 6fe1e1f..0000000 --- a/stubs/TestCase.stub +++ /dev/null @@ -1,80 +0,0 @@ - $originalClassName - * @phpstan-return MockObject&T - */ - public function createStub($originalClassName) {} - - /** - * @template T - * @phpstan-param class-string $originalClassName - * @phpstan-return MockObject&T - */ - public function createMock($originalClassName) {} - - /** - * @template T - * @phpstan-param class-string $className - * @phpstan-return MockBuilder - */ - public function getMockBuilder(string $className) {} - - /** - * @template T - * @phpstan-param class-string $originalClassName - * @phpstan-return MockObject&T - */ - public function createConfiguredMock($originalClassName) {} - - /** - * @template T - * @phpstan-param class-string $originalClassName - * @phpstan-param string[] $methods - * @phpstan-return MockObject&T - */ - public function createPartialMock($originalClassName, array $methods) {} - - /** - * @template T - * @phpstan-param class-string $originalClassName - * @phpstan-return MockObject&T - */ - public function createTestProxy($originalClassName) {} - - /** - * @template T - * @phpstan-param class-string $originalClassName - * @phpstan-param mixed[] $arguments - * @phpstan-param string $mockClassName - * @phpstan-param bool $callOriginalConstructor - * @phpstan-param bool $callOriginalClone - * @phpstan-param bool $callAutoload - * @phpstan-param string[] $mockedMethods - * @phpstan-param bool $cloneArguments - * @phpstan-return MockObject&T - */ - protected function getMockForAbstractClass($originalClassName, array $arguments = [], $mockClassName = '', $callOriginalConstructor = true, $callOriginalClone = true, $callAutoload = true, $mockedMethods = [], $cloneArguments = false) {} - - /** - * @template T - * @phpstan-param string $wsdlFile - * @phpstan-param class-string $originalClassName - * @phpstan-param string $mockClassName - * @phpstan-param string[] $methods - * @phpstan-param bool $callOriginalConstructor - * @phpstan-param mixed[] $options - * @phpstan-return MockObject&T - */ - protected function getMockFromWsdl($wsdlFile, $originalClassName = '', $mockClassName = '', array $methods = [], $callOriginalConstructor = true, array $options = []) {} - -} diff --git a/tests/Type/PHPUnit/CreateMockExtensionTest.php b/tests/Type/PHPUnit/CreateMockExtensionTest.php new file mode 100644 index 0000000..3994d15 --- /dev/null +++ b/tests/Type/PHPUnit/CreateMockExtensionTest.php @@ -0,0 +1,37 @@ +processFile( + __DIR__ . '/data/create-mock.php', + $expression, + $type, + [new CreateMockDynamicReturnTypeExtension()] + ); + } + + /** + * @return Iterator + */ + public function getProvider(): Iterator + { + yield ['$simpleInterface', implode('&', [FooInterface::class, MockObject::class])]; + yield ['$doubleInterface', implode('&', [BarInterface::class, FooInterface::class, MockObject::class])]; + } + +} diff --git a/tests/Type/PHPUnit/ExtensionTestCase.php b/tests/Type/PHPUnit/ExtensionTestCase.php new file mode 100644 index 0000000..c0bb043 --- /dev/null +++ b/tests/Type/PHPUnit/ExtensionTestCase.php @@ -0,0 +1,89 @@ + $extensions + */ + protected function processFile( + string $file, + string $expression, + string $type, + array $extensions + ): void + { + foreach ($extensions as $extension) { + if (!$extension instanceof DynamicMethodReturnTypeExtension) { + throw new \InvalidArgumentException(); + } + } + $broker = $this->createBroker($extensions); + $parser = $this->getParser(); + $currentWorkingDirectory = $this->getCurrentWorkingDirectory(); + $fileHelper = new FileHelper($currentWorkingDirectory); + $typeSpecifier = $this->createTypeSpecifier(new Standard(), $broker); + /** @var \PHPStan\PhpDoc\PhpDocStringResolver $phpDocStringResolver */ + $phpDocStringResolver = self::getContainer()->getByType(PhpDocStringResolver::class); + $resolver = new NodeScopeResolver( + $broker, + $parser, + new FileTypeMapper( + $parser, + $phpDocStringResolver, + self::getContainer()->getByType(PhpDocNodeResolver::class), + $this->createMock(Cache::class), + $this->createMock(AnonymousClassNameHelper::class) + ), + $fileHelper, + $typeSpecifier, + true, + true, + true, + [], + [] + ); + $resolver->setAnalysedFiles([$fileHelper->normalizePath($file)]); + + $run = false; + $resolver->processNodes( + $parser->parseFile($file), + $this->createScopeFactory($broker, $typeSpecifier)->create(ScopeContext::create($file)), + function (Node $node, Scope $scope) use ($expression, $type, &$run): void { + if ($node instanceof VirtualNode) { + return; + } + if ((new Standard())->prettyPrint([$node]) !== 'die') { + return; + } + /** @var \PhpParser\Node\Stmt\Expression $expNode */ + $expNode = $this->getParser()->parseString(sprintf('getType($expNode->expr)->describe(VerbosityLevel::typeOnly())); + $run = true; + } + ); + self::assertTrue($run); + } + +} diff --git a/tests/Type/PHPUnit/MockBuilderTypeExtensionTest.php b/tests/Type/PHPUnit/MockBuilderTypeExtensionTest.php new file mode 100644 index 0000000..896c766 --- /dev/null +++ b/tests/Type/PHPUnit/MockBuilderTypeExtensionTest.php @@ -0,0 +1,37 @@ +processFile( + __DIR__ . '/data/mock-builder.php', + $expression, + $type, + [new MockBuilderDynamicReturnTypeExtension(), new GetMockBuilderDynamicReturnTypeExtension()] + ); + } + + /** + * @return Iterator + */ + public function getProvider(): Iterator + { + yield ['$simpleInterface', implode('&', [FooInterface::class, MockObject::class])]; + yield ['$doubleInterface', implode('&', [BarInterface::class, FooInterface::class, MockObject::class])]; + } + +} diff --git a/tests/Type/PHPUnit/data/BarInterface.php b/tests/Type/PHPUnit/data/BarInterface.php new file mode 100644 index 0000000..4928f6c --- /dev/null +++ b/tests/Type/PHPUnit/data/BarInterface.php @@ -0,0 +1,9 @@ +getMethod('createMock')->setAccessible(true); +$simpleInterface = $test->createMock(\ExampleTestCase\FooInterface::class); +$doubleInterface = $test->createMock([\ExampleTestCase\FooInterface::class, \ExampleTestCase\BarInterface::class]); + +die; diff --git a/tests/Type/PHPUnit/data/mock-builder.php b/tests/Type/PHPUnit/data/mock-builder.php new file mode 100644 index 0000000..e28a80b --- /dev/null +++ b/tests/Type/PHPUnit/data/mock-builder.php @@ -0,0 +1,10 @@ +getMockBuilder(\ExampleTestCase\FooInterface::class)->getMock(); +$doubleInterface = $test->getMockBuilder([\ExampleTestCase\FooInterface::class, \ExampleTestCase\BarInterface::class])->getMock(); + +die;