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();
+ }
+}