diff --git a/src/Framework/MockObject/Exception/NoMoreParameterSetsConfiguredException.php b/src/Framework/MockObject/Exception/NoMoreParameterSetsConfiguredException.php new file mode 100644 index 00000000000..c6639577796 --- /dev/null +++ b/src/Framework/MockObject/Exception/NoMoreParameterSetsConfiguredException.php @@ -0,0 +1,32 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace PHPUnit\Framework\MockObject; + +use function sprintf; + +/** + * @no-named-arguments Parameter names are not covered by the backward compatibility promise for PHPUnit + * + * @internal This class is not covered by the backward compatibility promise for PHPUnit + */ +final class NoMoreParameterSetsConfiguredException extends \PHPUnit\Framework\Exception implements Exception +{ + public function __construct(Invocation $invocation, int $numberOfConfiguredParameterSets) + { + parent::__construct( + sprintf( + 'Not enough parameter sets configured, only %d parameter sets given for %s::%s()', + $numberOfConfiguredParameterSets, + $invocation->className(), + $invocation->methodName(), + ), + ); + } +} diff --git a/src/Framework/MockObject/Runtime/Interface/InvocationStubber.php b/src/Framework/MockObject/Runtime/Interface/InvocationStubber.php index 337c82fb3f3..e154bdd717e 100644 --- a/src/Framework/MockObject/Runtime/Interface/InvocationStubber.php +++ b/src/Framework/MockObject/Runtime/Interface/InvocationStubber.php @@ -48,6 +48,16 @@ public function after(string $id): self; */ public function with(mixed ...$arguments): self; + /** + * @return $this + */ + public function withConsecutiveParameterSets(mixed ...$arguments): self; + + /** + * @return $this + */ + public function withParameterSetsInAnyOrder(mixed ...$arguments): self; + /** * @no-named-arguments Parameter names are not covered by the backward compatibility promise for PHPUnit * diff --git a/src/Framework/MockObject/Runtime/InvocationStubberImplementation.php b/src/Framework/MockObject/Runtime/InvocationStubberImplementation.php index 3aca45a294b..1da24e3eefc 100644 --- a/src/Framework/MockObject/Runtime/InvocationStubberImplementation.php +++ b/src/Framework/MockObject/Runtime/InvocationStubberImplementation.php @@ -139,6 +139,24 @@ public function with(mixed ...$arguments): InvocationStubber return $this; } + public function withConsecutiveParameterSets(mixed ...$arguments): InvocationStubber + { + $this->ensureParametersCanBeConfigured(); + + $this->matcher->setParametersRule(new Rule\ConsecutiveParameterSets($arguments)); + + return $this; + } + + public function withParameterSetsInAnyOrder(mixed ...$arguments): InvocationStubber + { + $this->ensureParametersCanBeConfigured(); + + $this->matcher->setParametersRule(new Rule\UnorderedParameterSets($arguments)); + + return $this; + } + /** * @throws MethodNameNotConfiguredException * @throws MethodParametersAlreadyConfiguredException diff --git a/src/Framework/MockObject/Runtime/Rule/ConsecutiveParameterSets.php b/src/Framework/MockObject/Runtime/Rule/ConsecutiveParameterSets.php new file mode 100644 index 00000000000..9a7cc499ba6 --- /dev/null +++ b/src/Framework/MockObject/Runtime/Rule/ConsecutiveParameterSets.php @@ -0,0 +1,87 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace PHPUnit\Framework\MockObject\Rule; + +use function array_shift; +use function count; +use function is_array; +use function sprintf; +use PHPUnit\Framework\ExpectationFailedException; +use PHPUnit\Framework\MockObject\Invocation as BaseInvocation; +use PHPUnit\Framework\MockObject\NoMoreParameterSetsConfiguredException; + +/** + * @no-named-arguments Parameter names are not covered by the backward compatibility promise for PHPUnit + * + * @internal This class is not covered by the backward compatibility promise for PHPUnit + */ +final class ConsecutiveParameterSets implements ParametersRule +{ + /** + * @var list + */ + private array $stack = []; + + /** + * @var list + */ + private array $applied = []; + private int $numberOfConfiguredParameterSets = 0; + + /** + * @param list $stack + */ + public function __construct(array $stack) + { + foreach ($stack as $parameters) { + $this->stack[] = new Parameters(is_array($parameters) ? $parameters : [$parameters]); + } + $this->numberOfConfiguredParameterSets = count($stack); + } + + public function apply(BaseInvocation $invocation): void + { + if ($this->stack === []) { + throw new NoMoreParameterSetsConfiguredException( + $invocation, + $this->numberOfConfiguredParameterSets, + ); + } + + $parameters = array_shift($this->stack); + $this->applied[] = $parameters; + $parameters->apply($invocation); + } + + /** + * Checks if the invocation $invocation matches the current rules. If it + * does the rule will get the invoked() method called which should check + * if an expectation is met. + * + * @throws ExpectationFailedException + */ + public function verify(): void + { + if (count($this->applied) !== $this->numberOfConfiguredParameterSets && + count($this->stack) > 0) { + throw new ExpectationFailedException( + sprintf( + 'Too much parameter sets given, %d from %d expected parameter sets have been called.', + count($this->applied), + $this->numberOfConfiguredParameterSets, + ), + ); + } + + foreach ($this->applied as $parameters) { + $parameters->verify(); + } + } +} diff --git a/src/Framework/MockObject/Runtime/Rule/Parameters.php b/src/Framework/MockObject/Runtime/Rule/Parameters.php index 17df3f0a73a..310c6986753 100644 --- a/src/Framework/MockObject/Runtime/Rule/Parameters.php +++ b/src/Framework/MockObject/Runtime/Rule/Parameters.php @@ -11,6 +11,7 @@ use function count; use function sprintf; +use AllowDynamicProperties; use Exception; use PHPUnit\Framework\Constraint\Callback; use PHPUnit\Framework\Constraint\Constraint; @@ -25,7 +26,7 @@ * * @internal This class is not covered by the backward compatibility promise for PHPUnit */ -final class Parameters implements ParametersRule +#[AllowDynamicProperties] final class Parameters implements ParametersRule { /** * @var list @@ -33,6 +34,7 @@ final class Parameters implements ParametersRule private array $parameters = []; private ?BaseInvocation $invocation = null; private null|bool|ExpectationFailedException $parameterVerificationResult; + private bool $useAssertionCount = true; /** * @param array $parameters @@ -81,6 +83,11 @@ public function verify(): void $this->doVerify(); } + public function useAssertionCount(bool $useAssertionCount): void + { + $this->useAssertionCount = $useAssertionCount; + } + /** * @throws ExpectationFailedException */ @@ -149,6 +156,9 @@ private function guardAgainstDuplicateEvaluationOfParameterConstraints(): bool private function incrementAssertionCount(): void { + if ($this->useAssertionCount === false) { + return; + } Test::currentTestCase()->addToAssertionCount(1); } } diff --git a/src/Framework/MockObject/Runtime/Rule/UnorderedParameterSets.php b/src/Framework/MockObject/Runtime/Rule/UnorderedParameterSets.php new file mode 100644 index 00000000000..0f2b976a82e --- /dev/null +++ b/src/Framework/MockObject/Runtime/Rule/UnorderedParameterSets.php @@ -0,0 +1,110 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace PHPUnit\Framework\MockObject\Rule; + +use function array_search; +use function array_shift; +use function count; +use function implode; +use function is_array; +use function sprintf; +use PHPUnit\Framework\ExpectationFailedException; +use PHPUnit\Framework\MockObject\Invocation as BaseInvocation; +use PHPUnit\Framework\MockObject\NoMoreParameterSetsConfiguredException; + +final class UnorderedParameterSets implements ParametersRule +{ + /** + * @var list + */ + private array $stack = []; + + /** + * @var list + */ + private array $unapplied = []; + + /** + * @var list + */ + private array $applied = []; + private int $numberOfConfiguredParameterSets = 0; + + /** + * @param list $stack + */ + public function __construct(array $stack) + { + foreach ($stack as $parameters) { + $this->stack[] = new Parameters(is_array($parameters) ? $parameters : [$parameters]); + } + $this->unapplied = $this->stack; + $this->numberOfConfiguredParameterSets = count($stack); + } + + public function apply(BaseInvocation $invocation): void + { + if ($this->unapplied === []) { + throw new NoMoreParameterSetsConfiguredException( + $invocation, + $this->numberOfConfiguredParameterSets, + ); + } + + $checkedParameters = 0; + $unappliedParameters = count($this->unapplied); + + while ($checkedParameters < $unappliedParameters) { + $checkedParameters++; + $parameters = array_shift($this->unapplied); + + try { + $parameters->useAssertionCount(false); + $parameters->apply($invocation); + + $this->applied[] = $parameters; + + $parameters->useAssertionCount(true); + $parameters->apply($invocation); + + break; + } catch (ExpectationFailedException $e) { + $this->unapplied[] = $parameters; + } + } + } + + /** + * Checks if the invocation $invocation matches the current rules. If it + * does the rule will get the invoked() method called which should check + * if an expectation is met. + * + * @throws ExpectationFailedException + */ + public function verify(): void + { + if (count($this->applied) !== $this->numberOfConfiguredParameterSets && + count($this->unapplied) > 0) { + $unappliedIndexes = []; + + foreach ($this->unapplied as $parameters) { + $unappliedIndexes[] = array_search($parameters, $this->stack, true); + } + + throw new ExpectationFailedException( + sprintf( + '%d from %d expected parameter sets were called, indexes [' . implode(', ', $unappliedIndexes) . '] were not called.', + count($this->applied), + $this->numberOfConfiguredParameterSets, + ), + ); + } + } +} diff --git a/tests/end-to-end/regression/6407-multiple-arguments.phpt b/tests/end-to-end/regression/6407-multiple-arguments.phpt new file mode 100644 index 00000000000..433676de1d3 --- /dev/null +++ b/tests/end-to-end/regression/6407-multiple-arguments.phpt @@ -0,0 +1,21 @@ +--TEST-- +https://github.com/sebastianbergmann/phpunit/issues/6407 +--FILE-- +run($_SERVER['argv']); +--EXPECTF-- +PHPUnit %s by Sebastian Bergmann and contributors. + +Runtime: %s + +.. 2 / 2 (100%) + +Time: %s, Memory: %s + +OK (2 tests, 10 assertions) diff --git a/tests/end-to-end/regression/6407-single-arguments.phpt b/tests/end-to-end/regression/6407-single-arguments.phpt new file mode 100644 index 00000000000..937c767a45d --- /dev/null +++ b/tests/end-to-end/regression/6407-single-arguments.phpt @@ -0,0 +1,21 @@ +--TEST-- +https://github.com/sebastianbergmann/phpunit/issues/6407 +--FILE-- +run($_SERVER['argv']); +--EXPECTF-- +PHPUnit %s by Sebastian Bergmann and contributors. + +Runtime: %s + +.. 2 / 2 (100%) + +Time: %s, Memory: %s + +OK (2 tests, 6 assertions) diff --git a/tests/end-to-end/regression/6407/Issue6407MultipleArgumentTest.php b/tests/end-to-end/regression/6407/Issue6407MultipleArgumentTest.php new file mode 100644 index 00000000000..dd980835337 --- /dev/null +++ b/tests/end-to-end/regression/6407/Issue6407MultipleArgumentTest.php @@ -0,0 +1,53 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace PHPUnit\TestFixture\Issue6407; + +use PHPUnit\Framework\TestCase; + +require __DIR__ . '/src/multiple/Logger.php'; + +require __DIR__ . '/src/multiple/Service.php'; + +final class Issue6407MultipleArgumentTest extends TestCase +{ + public function testWithOrderedParametersList(): void + { + $logger = $this->createMock(Logger::class); + + $logger + ->expects($this->exactly(2)) + ->method('log') + ->withConsecutiveParameterSets( + ['info', 'Some Info'], + ['error', 'Some Error'], + ); + + $service = new Service($logger); + + $service->doSomething(); + } + + public function testWithUnOrderedParametersList(): void + { + $logger = $this->createMock(Logger::class); + + $logger + ->expects($this->exactly(2)) + ->method('log') + ->withParameterSetsInAnyOrder( + ['error', 'Some Error'], + ['info', 'Some Info'], + ); + + $service = new Service($logger); + + $service->doSomething(); + } +} diff --git a/tests/end-to-end/regression/6407/Issue6407SingleArgumentTest.php b/tests/end-to-end/regression/6407/Issue6407SingleArgumentTest.php new file mode 100644 index 00000000000..374fdc7fdcb --- /dev/null +++ b/tests/end-to-end/regression/6407/Issue6407SingleArgumentTest.php @@ -0,0 +1,59 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace PHPUnit\TestFixture\Issue6407; + +use PHPUnit\Framework\TestCase; + +require __DIR__ . '/src/single/Dispatcher.php'; + +require __DIR__ . '/src/single/Event.php'; + +require __DIR__ . '/src/single/AnEvent.php'; + +require __DIR__ . '/src/single/AnotherEvent.php'; + +require __DIR__ . '/src/single/Service.php'; + +final class Issue6407SingleArgumentTest extends TestCase +{ + public function testWithOrderedParametersList(): void + { + $dispatcher = $this->createMock(Dispatcher::class); + + $dispatcher + ->expects($this->exactly(2)) + ->method('dispatch') + ->withConsecutiveParameterSets( + $this->isInstanceOf(AnEvent::class), + $this->isInstanceOf(AnotherEvent::class), + ); + + $service = new Service($dispatcher); + + $service->doSomething(); + } + + public function testWithUnOrderedParametersList(): void + { + $dispatcher = $this->createMock(Dispatcher::class); + + $dispatcher + ->expects($this->exactly(2)) + ->method('dispatch') + ->withParameterSetsInAnyOrder( + $this->isInstanceOf(AnotherEvent::class), + $this->isInstanceOf(AnEvent::class), + ); + + $service = new Service($dispatcher); + + $service->doSomething(); + } +} diff --git a/tests/end-to-end/regression/6407/src/multiple/Logger.php b/tests/end-to-end/regression/6407/src/multiple/Logger.php new file mode 100644 index 00000000000..58d1f2c1404 --- /dev/null +++ b/tests/end-to-end/regression/6407/src/multiple/Logger.php @@ -0,0 +1,15 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace PHPUnit\TestFixture\Issue6407; + +interface Logger +{ + public function log(string $level, string $message): void; +} diff --git a/tests/end-to-end/regression/6407/src/multiple/Service.php b/tests/end-to-end/regression/6407/src/multiple/Service.php new file mode 100644 index 00000000000..37f3e1a2c67 --- /dev/null +++ b/tests/end-to-end/regression/6407/src/multiple/Service.php @@ -0,0 +1,26 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace PHPUnit\TestFixture\Issue6407; + +final readonly class Service +{ + private Logger $logger; + + public function __construct(Logger $logger) + { + $this->logger = $logger; + } + + public function doSomething(): void + { + $this->logger->log('info', 'Some Info'); + $this->logger->log('error', 'Some Error'); + } +} diff --git a/tests/end-to-end/regression/6407/src/single/AnEvent.php b/tests/end-to-end/regression/6407/src/single/AnEvent.php new file mode 100644 index 00000000000..a4b608b73b8 --- /dev/null +++ b/tests/end-to-end/regression/6407/src/single/AnEvent.php @@ -0,0 +1,14 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace PHPUnit\TestFixture\Issue6407; + +final readonly class AnEvent implements Event +{ +} diff --git a/tests/end-to-end/regression/6407/src/single/AnotherEvent.php b/tests/end-to-end/regression/6407/src/single/AnotherEvent.php new file mode 100644 index 00000000000..e4bc8a9e162 --- /dev/null +++ b/tests/end-to-end/regression/6407/src/single/AnotherEvent.php @@ -0,0 +1,14 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace PHPUnit\TestFixture\Issue6407; + +final readonly class AnotherEvent implements Event +{ +} diff --git a/tests/end-to-end/regression/6407/src/single/Dispatcher.php b/tests/end-to-end/regression/6407/src/single/Dispatcher.php new file mode 100644 index 00000000000..79f47cde107 --- /dev/null +++ b/tests/end-to-end/regression/6407/src/single/Dispatcher.php @@ -0,0 +1,15 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace PHPUnit\TestFixture\Issue6407; + +interface Dispatcher +{ + public function dispatch(Event $event): void; +} diff --git a/tests/end-to-end/regression/6407/src/single/Event.php b/tests/end-to-end/regression/6407/src/single/Event.php new file mode 100644 index 00000000000..649d0a0d0f0 --- /dev/null +++ b/tests/end-to-end/regression/6407/src/single/Event.php @@ -0,0 +1,14 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace PHPUnit\TestFixture\Issue6407; + +interface Event +{ +} diff --git a/tests/end-to-end/regression/6407/src/single/Service.php b/tests/end-to-end/regression/6407/src/single/Service.php new file mode 100644 index 00000000000..37da4cb185f --- /dev/null +++ b/tests/end-to-end/regression/6407/src/single/Service.php @@ -0,0 +1,26 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace PHPUnit\TestFixture\Issue6407; + +final readonly class Service +{ + private Dispatcher $dispatcher; + + public function __construct(Dispatcher $dispatcher) + { + $this->dispatcher = $dispatcher; + } + + public function doSomething(): void + { + $this->dispatcher->dispatch(new AnEvent); + $this->dispatcher->dispatch(new AnotherEvent); + } +} diff --git a/tests/unit/Framework/MockObject/MockObjectTest.php b/tests/unit/Framework/MockObject/MockObjectTest.php index 9b604ba2106..b6a4194fb28 100644 --- a/tests/unit/Framework/MockObject/MockObjectTest.php +++ b/tests/unit/Framework/MockObject/MockObjectTest.php @@ -318,6 +318,127 @@ public function testExpectationThatMethodIsCalledWithParameterFailsWhenMethodIsC ); } + public function testExpectationThatMethodIsCalledWithConsecutiveParameterSetsSucceedsWhenMethodIsCalledConsecutiveWithExpectedParameters(): void + { + $double = $this->createMock(InterfaceWithReturnTypeDeclaration::class); + + $double->expects($this->exactly(2))->method('doSomethingElse')->withConsecutiveParameterSets(1, 2); + + $double->doSomethingElse(1); + $double->doSomethingElse(2); + } + + public function testExpectationThatMethodIsCalledWithConsecutiveParameterSetsFailedWhenMethodIsCalledConsecutiveWithOneWrongParameter(): void + { + $double = $this->createMock(InterfaceWithReturnTypeDeclaration::class); + + $double->expects($this->exactly(2))->method('doSomethingElse')->withConsecutiveParameterSets(1, 2); + + $double->doSomethingElse(1); + $this->assertThatMockObjectExpectationFails( + <<<'EOT' +Expectation failed for method name is "doSomethingElse" when invoked 2 times +Parameter 0 for invocation PHPUnit\TestFixture\MockObject\InterfaceWithReturnTypeDeclaration::doSomethingElse(3): int does not match expected value. +Failed asserting that 3 matches expected 2. +EOT, + $double, + 'doSomethingElse', + [3], + ); + } + + public function testExpectationThatMethodIsCalledWithConsecutiveParameterSetsFailedWhenMethodIsCalledConsecutiveWithNotEnoughParameter(): void + { + $double = $this->createMock(InterfaceWithReturnTypeDeclaration::class); + + $double->expects($this->exactly(3))->method('doSomethingElse')->withConsecutiveParameterSets(1, 2); + + $this->expectException(NoMoreParameterSetsConfiguredException::class); + $this->expectExceptionMessage('Not enough parameter sets configured, only 2 parameter sets given for PHPUnit\TestFixture\MockObject\InterfaceWithReturnTypeDeclaration::doSomethingElse()'); + + $double->doSomethingElse(1); + $double->doSomethingElse(2); + $double->doSomethingElse(3); + } + + public function testExpectationThatMethodIsCalledWithConsecutiveParameterSetsFailedWhenTooMuchParameterSetsAreGiven(): void + { + $double = $this->createMock(InterfaceWithReturnTypeDeclaration::class); + + $double->expects($this->exactly(2))->method('doSomethingElse')->withConsecutiveParameterSets(1, 2, 3); + + $double->doSomethingElse(1); + $double->doSomethingElse(2); + $this->assertThatMockObjectExpectationFails( + <<<'EOT' +Expectation failed for method name is "doSomethingElse" when invoked 2 times. +Too much parameter sets given, 2 from 3 expected parameter sets have been called. + +EOT, + $double, + ); + } + + public function testExpectationThatMethodIsCalledWithParameterSetsInAnyOrderSucceedsWhenMethodIsCalledWithExpectedParameters(): void + { + $double = $this->createMock(InterfaceWithReturnTypeDeclaration::class); + + $double->expects($this->exactly(2))->method('doSomethingElse')->withParameterSetsInAnyOrder(1, 2); + + $double->doSomethingElse(2); + $double->doSomethingElse(1); + } + + public function testExpectationThatMethodIsCalledWithParameterSetsInAnyOrderFailedWhenMethodIsCalledWithOneWrongParameter(): void + { + $double = $this->createMock(InterfaceWithReturnTypeDeclaration::class); + + $double->expects($this->exactly(2))->method('doSomethingElse')->withParameterSetsInAnyOrder(1, 2); + + $double->doSomethingElse(3); + $double->doSomethingElse(1); + $this->assertThatMockObjectExpectationFails( + <<<'EOT' +Expectation failed for method name is "doSomethingElse" when invoked 2 times. +1 from 2 expected parameter sets were called, indexes [1] were not called. + +EOT, + $double, + ); + } + + public function testExpectationThatMethodIsCalledWithParameterSetsInAnyOrderFailedWhenTooMuchParameterSetsAreGiven(): void + { + $double = $this->createMock(InterfaceWithReturnTypeDeclaration::class); + + $double->expects($this->exactly(2))->method('doSomethingElse')->withParameterSetsInAnyOrder(1, 2, 3); + + $double->doSomethingElse(3); + $double->doSomethingElse(2); + $this->assertThatMockObjectExpectationFails( + <<<'EOT' +Expectation failed for method name is "doSomethingElse" when invoked 2 times. +2 from 3 expected parameter sets were called, indexes [0] were not called. + +EOT, + $double, + ); + } + + public function testExpectationThatMethodIsCalledWithParameterSetsInAnyOrderFailedWhenMethodIsCalledConsecutiveWithNotEnoughParameter(): void + { + $double = $this->createMock(InterfaceWithReturnTypeDeclaration::class); + + $double->expects($this->exactly(3))->method('doSomethingElse')->withParameterSetsInAnyOrder(1, 2); + + $this->expectException(NoMoreParameterSetsConfiguredException::class); + $this->expectExceptionMessage('Not enough parameter sets configured, only 2 parameter sets given for PHPUnit\TestFixture\MockObject\InterfaceWithReturnTypeDeclaration::doSomethingElse()'); + + $double->doSomethingElse(1); + $double->doSomethingElse(2); + $double->doSomethingElse(3); + } + /** * With $double->expects($this->once())->method('one')->id($id);, * we configure an expectation that one() is called once. This expectation is given the ID $id.