Repository: sebastianbergmann/phpunit (php)

Issue:
False error on `atMost(...)` invocation rule without call
| Q                   | A
| --------------------| ---------------
| PHPUnit version     | 9.5.20 & 8.5.26
| PHP version         | 8.1.4
| Installation Method | PHAR

#### Summary

When using the `atMost(...)` invocation rule, and the UUT does not invoke the mocked function, then I get the following error:
```
Expectation failed for method name is "mockedMethodName" when invoked at most 1 times.
Mocked method does not exist.
```

#### Current behavior

Test fails with error, when mocked method never called with `atMost(...)` invocation rule.

#### How to reproduce

1. Mock a class.
2. Make an invocation rule with `atMost(1)` for example, but do not call the mocked method from the tested unit.
3. Run the test.

My example call:
```PHP
$this->storesRepository->expects(self::atMost(1))
  ->method('findOneBy')
  ->with(['name' => $storeName])
  ->willReturn($storeEntityWithSameName);
```

#### Expected behavior

Test passes as zero calls is smaller then the value passed to the `atMost(...)` invocation rule.


Pull request description:
Fix `atMost(...)` invocation rule throws error on zero invocation
Closes #4950

Patch:
diff --git a/.phive/phars.xml b/.phive/phars.xml
index c04a03db6..eaa4f4883 100644
--- a/.phive/phars.xml
+++ b/.phive/phars.xml
@@ -6,5 +6,5 @@
   <phar name="phploc" version="^4.0" installed="4.0.1" location="./tools/phploc" copy="true"/>
   <phar name="psalm" version="^4.0" installed="4.22.0" location="./tools/psalm" copy="true"/>
   <phar name="humbug/php-scoper" version="^0.17.0" installed="0.17.2" location="./tools/php-scoper" copy="true"/>
-  <phar name="composer" version="^2.0.3" installed="2.3.3" location="./tools/composer" copy="true"/>
+  <phar name="composer" version="^2.0.3" installed="2.3.4" location="./tools/composer" copy="true"/>
 </phive>
diff --git a/phpunit.xsd b/phpunit.xsd
index 1ca094cb4..75e222894 100644
--- a/phpunit.xsd
+++ b/phpunit.xsd
@@ -309,8 +309,10 @@
     </xs:complexType>
     <xs:complexType name="testSuiteType">
         <xs:sequence>
-            <xs:group ref="pathGroup"/>
-            <xs:element name="exclude" type="xs:string" minOccurs="0" maxOccurs="unbounded"/>
+            <xs:choice minOccurs="0" maxOccurs="unbounded">
+                <xs:group ref="pathGroup"/>
+                <xs:element name="exclude" type="xs:string"/>
+            </xs:choice>
         </xs:sequence>
         <xs:attribute name="name" type="xs:string" use="required"/>
     </xs:complexType>
diff --git a/src/Framework/MockObject/Matcher.php b/src/Framework/MockObject/Matcher.php
index 2b1cd8c86..375a35cdd 100644
--- a/src/Framework/MockObject/Matcher.php
+++ b/src/Framework/MockObject/Matcher.php
@@ -17,6 +17,7 @@
 use PHPUnit\Framework\MockObject\Rule\AnyInvokedCount;
 use PHPUnit\Framework\MockObject\Rule\AnyParameters;
 use PHPUnit\Framework\MockObject\Rule\InvocationOrder;
+use PHPUnit\Framework\MockObject\Rule\InvokedAtMostCount;
 use PHPUnit\Framework\MockObject\Rule\InvokedCount;
 use PHPUnit\Framework\MockObject\Rule\MethodName;
 use PHPUnit\Framework\MockObject\Rule\ParametersRule;
@@ -231,10 +232,11 @@ public function verify(): void
                 $this->parametersRule = new AnyParameters;
             }
 
-            $invocationIsAny   = $this->invocationRule instanceof AnyInvokedCount;
-            $invocationIsNever = $this->invocationRule instanceof InvokedCount && $this->invocationRule->isNever();
+            $invocationIsAny    = $this->invocationRule instanceof AnyInvokedCount;
+            $invocationIsNever  = $this->invocationRule instanceof InvokedCount && $this->invocationRule->isNever();
+            $invocationIsAtMost = $this->invocationRule instanceof InvokedAtMostCount;
 
-            if (!$invocationIsAny && !$invocationIsNever) {
+            if (!$invocationIsAny && !$invocationIsNever && !$invocationIsAtMost) {
                 $this->parametersRule->verify();
             }
         } catch (ExpectationFailedException $e) {


Test patch:
diff --git a/tests/unit/Framework/MockObject/MatcherTest.php b/tests/unit/Framework/MockObject/MatcherTest.php
index 57e943260..3a8914f29 100644
--- a/tests/unit/Framework/MockObject/MatcherTest.php
+++ b/tests/unit/Framework/MockObject/MatcherTest.php
@@ -117,4 +117,22 @@ public function testStubIsInvokedIfAllMatchersAndRulesApply(): void
 
         $matcher->invoked($invocation);
     }
+
+    public function testAtMostNotFailsWithoutInvocation(): void
+    {
+        $invocationMatcher = $this->createStub(InvocationOrder::class);
+        $invocation        = new Invocation('Foo', 'bar', [], 'void', new stdClass);
+
+        $stub = $this->createMock(Stub\Stub::class);
+        $stub->expects($this->atMost(1))
+             ->method('invoke')
+             ->with($invocation);
+
+        $parameterRule = $this->createStub(ParametersRule::class);
+
+        $matcher = new Matcher($invocationMatcher);
+        $matcher->setMethodNameRule(new MethodName('bar'));
+        $matcher->setParametersRule($parameterRule);
+        $matcher->setStub($stub);
+    }
 }