diff --git a/extension.neon b/extension.neon index 3395ec1..dbedee7 100644 --- a/extension.neon +++ b/extension.neon @@ -5,6 +5,7 @@ parameters: - markTestIncomplete - markTestSkipped stubFiles: + - stubs/InvocationMocker.stub - stubs/MockBuilder.stub - stubs/MockObject.stub - stubs/TestCase.stub @@ -26,7 +27,15 @@ services: class: PHPStan\Type\PHPUnit\Assert\AssertStaticMethodTypeSpecifyingExtension tags: - phpstan.typeSpecifier.staticMethodTypeSpecifyingExtension + - + class: PHPStan\Type\PHPUnit\InvocationMockerDynamicReturnTypeExtension + tags: + - phpstan.broker.dynamicMethodReturnTypeExtension - class: PHPStan\Type\PHPUnit\MockBuilderDynamicReturnTypeExtension tags: - phpstan.broker.dynamicMethodReturnTypeExtension + - + class: PHPStan\Type\PHPUnit\MockObjectDynamicReturnTypeExtension + tags: + - phpstan.broker.dynamicMethodReturnTypeExtension diff --git a/rules.neon b/rules.neon index ec02f7d..a5031f8 100644 --- a/rules.neon +++ b/rules.neon @@ -2,3 +2,4 @@ rules: - PHPStan\Rules\PHPUnit\AssertSameBooleanExpectedRule - PHPStan\Rules\PHPUnit\AssertSameNullExpectedRule - PHPStan\Rules\PHPUnit\AssertSameWithCountRule + - PHPStan\Rules\PHPUnit\MockMethodCallRule diff --git a/src/Rules/PHPUnit/MockMethodCallRule.php b/src/Rules/PHPUnit/MockMethodCallRule.php new file mode 100644 index 0000000..8a00af6 --- /dev/null +++ b/src/Rules/PHPUnit/MockMethodCallRule.php @@ -0,0 +1,85 @@ + + */ +class MockMethodCallRule implements \PHPStan\Rules\Rule +{ + + public function getNodeType(): string + { + return Node\Expr\MethodCall::class; + } + + public function processNode(Node $node, Scope $scope): array + { + /** @var Node\Expr\MethodCall $node */ + $node = $node; + + if (!$node->name instanceof Node\Identifier || $node->name->name !== 'method') { + return []; + } + + if (count($node->args) < 1) { + return []; + } + + $argType = $scope->getType($node->args[0]->value); + if (!($argType instanceof ConstantStringType)) { + return []; + } + + $method = $argType->getValue(); + $type = $scope->getType($node->var); + + if ( + $type instanceof IntersectionType + && in_array(MockObject::class, $type->getReferencedClasses(), true) + && !$type->hasMethod($method)->yes() + ) { + $mockClass = array_filter($type->getReferencedClasses(), function (string $class): bool { + return $class !== MockObject::class; + }); + + return [ + sprintf( + 'Trying to mock an undefined method %s() on class %s.', + $method, + \implode('&', $mockClass) + ), + ]; + } + + if ( + $type instanceof GenericObjectType + && $type->getClassName() === InvocationMocker::class + && count($type->getTypes()) > 0 + ) { + $mockClass = $type->getTypes()[0]; + + if ($mockClass instanceof ObjectType && !$mockClass->hasMethod($method)->yes()) { + return [ + sprintf( + 'Trying to mock an undefined method %s() on class %s.', + $method, + $mockClass->getClassName() + ), + ]; + } + } + + return []; + } + +} diff --git a/src/Type/PHPUnit/InvocationMockerDynamicReturnTypeExtension.php b/src/Type/PHPUnit/InvocationMockerDynamicReturnTypeExtension.php new file mode 100644 index 0000000..51e2222 --- /dev/null +++ b/src/Type/PHPUnit/InvocationMockerDynamicReturnTypeExtension.php @@ -0,0 +1,29 @@ +getName() !== 'getMatcher'; + } + + public function getTypeFromMethodCall(MethodReflection $methodReflection, MethodCall $methodCall, Scope $scope): Type + { + return $scope->getType($methodCall->var); + } + +} diff --git a/src/Type/PHPUnit/MockObjectDynamicReturnTypeExtension.php b/src/Type/PHPUnit/MockObjectDynamicReturnTypeExtension.php new file mode 100644 index 0000000..828fac7 --- /dev/null +++ b/src/Type/PHPUnit/MockObjectDynamicReturnTypeExtension.php @@ -0,0 +1,42 @@ +getName() === 'expects'; + } + + public function getTypeFromMethodCall(MethodReflection $methodReflection, MethodCall $methodCall, Scope $scope): Type + { + $type = $scope->getType($methodCall->var); + if (!($type instanceof IntersectionType)) { + return new GenericObjectType(InvocationMocker::class, []); + } + + $mockClasses = array_filter($type->getTypes(), function (Type $type): bool { + return !$type instanceof TypeWithClassName || $type->getClassName() !== MockObject::class; + }); + + return new GenericObjectType(InvocationMocker::class, $mockClasses); + } + +} diff --git a/stubs/InvocationMocker.stub b/stubs/InvocationMocker.stub new file mode 100644 index 0000000..c58719f --- /dev/null +++ b/stubs/InvocationMocker.stub @@ -0,0 +1,13 @@ + + */ +class MockMethodCallRuleTest extends \PHPStan\Testing\RuleTestCase +{ + + protected function getRule(): Rule + { + return new MockMethodCallRule(); + } + + public function testRule(): void + { + $this->analyse([__DIR__ . '/data/mock-method-call.php'], [ + [ + 'Trying to mock an undefined method doBadThing() on class MockMethodCall\Bar.', + 15, + ], + [ + 'Trying to mock an undefined method doBadThing() on class MockMethodCall\Bar.', + 20, + ], + ]); + } + + /** + * @return string[] + */ + public static function getAdditionalConfigFiles(): array + { + return [ + __DIR__ . '/../../../extension.neon', + ]; + } + +} diff --git a/tests/Rules/PHPUnit/data/mock-method-call.php b/tests/Rules/PHPUnit/data/mock-method-call.php new file mode 100644 index 0000000..cbe5942 --- /dev/null +++ b/tests/Rules/PHPUnit/data/mock-method-call.php @@ -0,0 +1,43 @@ +createMock(Bar::class)->method('doThing'); + } + + public function testBadMethod() + { + $this->createMock(Bar::class)->method('doBadThing'); + } + + public function testBadMethodWithExpectation() + { + $this->createMock(Bar::class)->expects($this->once())->method('doBadThing'); + } + + public function testWithAnotherObject() + { + $bar = new BarWithMethod(); + $bar->method('doBadThing'); + } + +} + +class Bar { + public function doThing() + { + return 1; + } +}; + +class BarWithMethod { + public function method(string $string) + { + return $string; + } +};