diff --git a/README.md b/README.md index 801a488b..a92ab91e 100644 --- a/README.md +++ b/README.md @@ -202,6 +202,35 @@ $this->realService = new Service(); :+1: +
+ +### RequireAtLeastOneRule + +Disallow `atLeast(0)` on mock expectations, as it matches any number of calls (including zero) and provides no real verification. Require a value of `1` or higher. + +```yaml +rules: + - Rector\Mockstan\Rules\RequireAtLeastOneRule +``` + +```php +$someMock = $this->createMock(Service::class); +$someMock->expects($this->atLeast(0)) + ->method('calculate') + ->willReturn(10); +``` + +:x: + +```php +$someMock = $this->createMock(Service::class); +$someMock->expects($this->atLeast(1)) + ->method('calculate') + ->willReturn(10); +``` + +:+1: +
### NoMockOnlyTestRule diff --git a/config/phpunit-mocks-rules.neon b/config/phpunit-mocks-rules.neon index 8dc2fdf7..64464029 100644 --- a/config/phpunit-mocks-rules.neon +++ b/config/phpunit-mocks-rules.neon @@ -9,6 +9,7 @@ rules: # explicit expects() - Rector\Mockstan\Rules\ExplicitExpectsMockMethodRule - Rector\Mockstan\Rules\AvoidAnyExpectsRule + - Rector\Mockstan\Rules\RequireAtLeastOneRule # better alternative than mocks - Rector\Mockstan\Rules\NoDocumentMockingRule diff --git a/src/Enum/RuleIdentifier.php b/src/Enum/RuleIdentifier.php index 40801da9..2ac152a2 100644 --- a/src/Enum/RuleIdentifier.php +++ b/src/Enum/RuleIdentifier.php @@ -24,4 +24,6 @@ final class RuleIdentifier public const string FORBIDDEN_CLASS_TO_MOCK = 'mockstan.forbiddenClassToMock'; public const string AVOID_ANY_EXPECTS = 'mockstan.avoidAnyExpects'; + + public const string REQUIRE_AT_LEAST_ONE = 'mockstan.requireAtLeastOne'; } diff --git a/src/Rules/RequireAtLeastOneRule.php b/src/Rules/RequireAtLeastOneRule.php new file mode 100644 index 00000000..341b79e7 --- /dev/null +++ b/src/Rules/RequireAtLeastOneRule.php @@ -0,0 +1,66 @@ + + * + * @see \Rector\Mockstan\Tests\Rules\RequireAtLeastOneRule\RequireAtLeastOneRuleTest + */ +final class RequireAtLeastOneRule implements Rule +{ + public const string ERROR_MESSAGE = 'Using $this->atLeast(0) is meaningless, as it matches any number of calls. Use 1 or higher'; + + public function getNodeType(): string + { + return MethodCall::class; + } + + /** + * @param MethodCall $node + * @return IdentifierRuleError[] + */ + public function processNode(Node $node, Scope $scope): array + { + if (! TestClassDetector::isTestClass($scope)) { + return []; + } + + if (! NamingHelper::isName($node->name, 'atLeast')) { + return []; + } + + $args = $node->getArgs(); + if ($args === []) { + return []; + } + + $firstArgValue = $args[0]->value; + if (! $firstArgValue instanceof Int_) { + return []; + } + + if ($firstArgValue->value >= 1) { + return []; + } + + $identifierRuleError = RuleErrorBuilder::message(self::ERROR_MESSAGE) + ->identifier(RuleIdentifier::REQUIRE_AT_LEAST_ONE) + ->build(); + + return [$identifierRuleError]; + } +} diff --git a/tests/Rules/RequireAtLeastOneRule/Fixture/AtLeastZero.php b/tests/Rules/RequireAtLeastOneRule/Fixture/AtLeastZero.php new file mode 100644 index 00000000..f177abf0 --- /dev/null +++ b/tests/Rules/RequireAtLeastOneRule/Fixture/AtLeastZero.php @@ -0,0 +1,17 @@ +createMock(\stdClass::class); + + $mock->expects($this->atLeast(0)) + ->method('someMethod') + ->willReturn('value'); + } +} diff --git a/tests/Rules/RequireAtLeastOneRule/Fixture/SkipAtLeastOne.php b/tests/Rules/RequireAtLeastOneRule/Fixture/SkipAtLeastOne.php new file mode 100644 index 00000000..2c39c8a7 --- /dev/null +++ b/tests/Rules/RequireAtLeastOneRule/Fixture/SkipAtLeastOne.php @@ -0,0 +1,22 @@ +createMock(\stdClass::class); + + $mock->expects($this->atLeast(1)) + ->method('someMethod') + ->willReturn('value'); + + $anotherMock = $this->createMock(\stdClass::class); + $anotherMock->expects($this->atLeast(3)) + ->method('anotherMethod') + ->willReturn('value'); + } +} diff --git a/tests/Rules/RequireAtLeastOneRule/RequireAtLeastOneRuleTest.php b/tests/Rules/RequireAtLeastOneRule/RequireAtLeastOneRuleTest.php new file mode 100644 index 00000000..974b3be5 --- /dev/null +++ b/tests/Rules/RequireAtLeastOneRule/RequireAtLeastOneRuleTest.php @@ -0,0 +1,37 @@ +> $expectedErrorsWithLines + */ + #[DataProvider('provideData')] + public function testRule(string $filePath, array $expectedErrorsWithLines): void + { + $this->analyse([$filePath], $expectedErrorsWithLines); + } + + /** + * @return Iterator, mixed>> + */ + public static function provideData(): Iterator + { + yield [__DIR__ . '/Fixture/AtLeastZero.php', [[RequireAtLeastOneRule::ERROR_MESSAGE, 13]]]; + yield [__DIR__ . '/Fixture/SkipAtLeastOne.php', []]; + } + + protected function getRule(): Rule + { + return new RequireAtLeastOneRule(); + } +}