phpunit icon indicating copy to clipboard operation
phpunit copied to clipboard

Expect calls to the same method of a mock object but with different arguments

Open sebastianbergmann opened this issue 2 months ago • 2 comments

It should be possible to expect calls to the same method of a mock object but with different arguments.

Consider this code:

src/Event.php

<?php declare(strict_types=1);
namespace PHPUnit\TestFixture\Issue6406;

interface Event
{
}

src/AnEvent.php

<?php declare(strict_types=1);
namespace PHPUnit\TestFixture\Issue6406;

final readonly class AnEvent implements Event
{
}

src/AnotherEvent.php

<?php declare(strict_types=1);
namespace PHPUnit\TestFixture\Issue6406;

final readonly class AnotherEvent implements Event
{
}

src/Dispatcher.php

<?php declare(strict_types=1);
namespace PHPUnit\TestFixture\Issue6406;

interface Dispatcher
{
    public function dispatch(Event $event): void;
}

src/Service.php

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

In a test for Service we currently cannot (conveniently) configure that we expect two calls of the Dispatcher::dispatch() method, once with an instance of AnEvent and once with an instance of AnotherEvent:

tests/ServiceTest.php

<?php declare(strict_types=1);
namespace PHPUnit\TestFixture\Issue6406;

use PHPUnit\Framework\Assert;
use PHPUnit\Framework\TestCase;

interface Event
{
}

final readonly class AnEvent implements Event
{
}

final readonly class AnotherEvent implements Event
{
}

interface Dispatcher
{
    public function dispatch(Event $event): void;
}

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

final class ServiceTest extends TestCase
{
    public function testDoesNotWork(): void
    {
        $dispatcher = $this->createMock(Dispatcher::class);

        $dispatcher
            ->expects($this->exactly(2))
            ->method('dispatch')
            ->with($this->isInstanceOf(AnEvent::class))
            ->with($this->isInstanceOf(AnotherEvent::class));

        $service = new Service($dispatcher);

        $service->doSomething();
    }

    public function testAlsoDoesNotWork(): void
    {
        $dispatcher = $this->createMock(Dispatcher::class);

        $dispatcher
            ->expects($this->once())
            ->method('dispatch')
            ->with($this->isInstanceOf(AnEvent::class));

        $dispatcher
            ->expects($this->once())
            ->method('dispatch')
            ->with($this->isInstanceOf(AnotherEvent::class));

        $service = new Service($dispatcher);

        $service->doSomething();
    }

    public function testWorksButIsNotSufficient(): void
    {
        $dispatcher = $this->createMock(Dispatcher::class);

        $dispatcher
            ->expects($this->exactly(2))
            ->method('dispatch')
            ->with($this->logicalOr(
                $this->isInstanceOf(AnEvent::class),
                $this->isInstanceOf(AnotherEvent::class),
            ));

        $service = new Service($dispatcher);

        $service->doSomething();
    }

    public function testWorksButIsInconvenient(): void
    {
        $dispatcher = $this->createMock(Dispatcher::class);

        $dispatcher
            ->expects($this->exactly(2))
            ->method('dispatch')
            ->with($this->callback(
                new class
                {
                    private const EXPECTED = [AnEvent::class, AnotherEvent::class];
                    private int $invocation = 0;

                    public function __invoke(Event $event): bool
                    {
                        return $event::class === self::EXPECTED[$this->invocation++];
                    }
                }
            ));

        $service = new Service($dispatcher);

        $service->doSomething();
    }
}

Running the test shown above yields the output shown below:

PHPUnit 13.0-g18f2df6f02 by Sebastian Bergmann and contributors.

Runtime:       PHP 8.4.14

EF..                                                                4 / 4 (100%)

Time: 00:00.003, Memory: 6.00 MB

There was 1 error:

1) PHPUnit\TestFixture\Issue6406\ServiceTest::testDoesNotWork
PHPUnit\Framework\MockObject\MethodParametersAlreadyConfiguredException: Method parameters already configured

/usr/local/src/phpunit/src/Framework/MockObject/Runtime/InvocationStubberImplementation.php:287
/usr/local/src/phpunit/src/Framework/MockObject/Runtime/InvocationStubberImplementation.php:135
/usr/local/src/phpunit/Test.php:50
/usr/local/src/phpunit/src/Framework/TestCase.php:1316
/usr/local/src/phpunit/src/Framework/TestCase.php:517
/usr/local/src/phpunit/src/Framework/TestRunner/TestRunner.php:99
/usr/local/src/phpunit/src/Framework/TestCase.php:358
/usr/local/src/phpunit/src/Framework/TestSuite.php:374
/usr/local/src/phpunit/src/TextUI/TestRunner.php:64
/usr/local/src/phpunit/src/TextUI/Application.php:229

--

There was 1 failure:

1) PHPUnit\TestFixture\Issue6406\ServiceTest::testAlsoDoesNotWork
Expectation failed for method name is "dispatch" when invoked 1 time
Parameter 0 for invocation PHPUnit\TestFixture\Issue6406\Dispatcher::dispatch(PHPUnit\TestFixture\Issue6406\AnEvent Object ()): void does not match expected value.
Failed asserting that an instance of class PHPUnit\TestFixture\Issue6406\AnEvent is an instance of class PHPUnit\TestFixture\Issue6406\AnotherEvent.

/usr/local/src/phpunit/src/Framework/MockObject/Runtime/Matcher.php:117
/usr/local/src/phpunit/src/Framework/MockObject/Runtime/InvocationHandler.php:110
/usr/local/src/phpunit/Test.php:35
/usr/local/src/phpunit/Test.php:73
/usr/local/src/phpunit/src/Framework/TestCase.php:1316
/usr/local/src/phpunit/src/Framework/TestCase.php:517
/usr/local/src/phpunit/src/Framework/TestRunner/TestRunner.php:99
/usr/local/src/phpunit/src/Framework/TestCase.php:358
/usr/local/src/phpunit/src/Framework/TestSuite.php:374
/usr/local/src/phpunit/src/TextUI/TestRunner.php:64
/usr/local/src/phpunit/src/TextUI/Application.php:229

ERRORS!
Tests: 4, Assertions: 8, Errors: 1, Failures: 1.

sebastianbergmann avatar Nov 11 '25 10:11 sebastianbergmann

I’ve been thinking about how this could look in actual test code from a user perspective. My mental model distinguishes two main cases: – ordered parameter sets: calls must happen in the given order – unordered parameter sets: all sets must occur, but order doesn’t matter

For naming, I could imagine something along these lines: – withConsecutiveParameterSets() – for the ordered case – withParameterSetsInAnyOrder() – for the unordered case

That would make it very explicit in the test what is being asserted.

For the example above the code could look like (method under test has just one arguments):

<?php declare(strict_types=1);
namespace PHPUnit\TestFixture\Issue6406;

use PHPUnit\Framework\TestCase;

final class ServiceTest 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(AnEvent::class),
                $this->isInstanceOf(AnotherEvent::class)
            );

        $service = new Service($dispatcher);

        $service->doSomething();
    }
}

If the method under test has at least two arguments, the call could look like this:

<?php declare(strict_types=1);
namespace PHPUnit\TestFixture\Issue6406;

use PHPUnit\Framework\TestCase;

interface Logger
{
    public function log(string $level, string $message): void;
}

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');
    }
}

final class ServiceTest 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(
                [ 'info', 'Some Info' ],
                [ 'error', 'Some Error' ]
            );

        $service = new Service($logger);

        $service->doSomething();
    }
}

laloona avatar Nov 13 '25 18:11 laloona

Maybe this could be a way to got? https://github.com/sebastianbergmann/phpunit/pull/6416

laloona avatar Nov 17 '25 08:11 laloona