44

As method withConsecutive will be deleted in PHPUnit 10 (in 9.6 it's deprecated) I need to replace all of occurrences of this method to new code.

Try to find some solutions and didn't find any of reasonable solution.

For example, I have a code

    $this->personServiceMock->expects($this->exactly(2))
        ->method('prepare')
        ->withConsecutive(
            [$personFirst, $employeeFirst],
            [$personSecond, $employeeSecond],
        )
        ->willReturnOnConsecutiveCalls($personDTO, $personSecondDTO);

To which code should I replace withConsecutive ?

P.S. Documentation on official site still shows how use withConsecutive

2
  • 9
    Yeh, deprecating and removing the method without providing an alternative is disappointing :( Here's a discussion about it: github.com/sebastianbergmann/phpunit/issues/4026 and I don't see any good arguments for removing it. Commented Feb 22, 2023 at 14:43
  • 8
    @RomanKliuchko I don't see good arguments either. Unfortunately, Sebastian seems to remove interfaces all the time without considering the users of PHPUnit. The reason he gave for removing another method I extensively use was that he "didn't think" many people used it, unbelievable. Commented Mar 25, 2023 at 5:40

14 Answers 14

58

I have replaced withConsecutive with the following.

$matcher = $this->exactly(2);
$this->service
    ->expects($matcher)
    ->method('functionName')
    ->willReturnCallback(function (string $key, string $value) use ($matcher,$expected1, $expected2) {
        match ($matcher->numberOfInvocations()) {
            1 =>  $this->assertEquals($expected1, $value),
            2 =>  $this->assertEquals($expected2, $value),
        };
    });
Sign up to request clarification or add additional context in comments.

7 Comments

This solution simply works! Thank a lot for sharing
I've used your answer to create a Rector rule to automate this upgrade: github.com/rectorphp/rector-phpunit/pull/246 Thank you
Thank you Tomas for your appreciation. I am glad to hear that it helped you in Rector. I am really a fan of the Rector Package and have already used it in my project :)
@TomasVotruba Might make sense to mention Awais as an author in the commit, no? 😊
Do note that using $matcher->numberOfInvocations() is not part of the phpunit public API and might break in the future. See this comment and thread: github.com/sebastianbergmann/phpunit/issues/…
|
15

I've just upgraded to PHPUnit 10 and faced the same issue. Here's the solution I came to:

$this->personServiceMock
    ->method('prepare')
    ->willReturnCallback(fn($person, $employee) =>
        match([$person, $employee]) {
            [$personFirst, $employeeFirst] => $personDTO,
            [$personSecond, $employeeSecond] => $personSecondDTO
        }
    );

If the mocked method is passed something other than what's expected in the match block, PHP will throw a UnhandledMatchError.

Edit: Some comments have pointed out the limitation here of not knowing how many times the function has been called. This is a bit of a hack, but we could count the function calls manually like this:

// Keep reference of the arguments passed in an array:
$callParams = [];

$this->personServiceMock
    ->method('prepare')
// Pass the callParams array by reference:
    ->willReturnCallback(function($person, $employee)use(&$callParams) {
// Store the current arguments in the array:
        array_push($callParams, func_get_args());

        match([$person, $employee]) {
            [$personFirst, $employeeFirst] => $personDTO,
            [$personSecond, $employeeSecond] => $personSecondDTO
        }
    });

// Check that an expected argument call is present in the $callParams array:
self::assertContains(["Person1",  "Employee1"], $callParams);

2 Comments

This is good, but your solution doesn't count the order of running methods.
I was going to use this method as a replacement as well, but afaik the match function works more like a switch function (php.net/manual/de/control-structures.match.php) and only checks if the given parameter ($person, $employee) matches one of the conditions described in the match function (like [$personFirst, $employeeFirst]). Nevertheless you will not know if the function was called with all the conditions described. So you will not get an error if the prepare method is called mainly with [$personFirst, $employeeFirst] but never with [$personSecond, $employeeSecond].
8

For me the following worked:

$expected = ['value1', 'value2'];
$matcher = $this->exactly(count($expected));
$this->mockedObject->expects($matcher)->method('test')->with(
   $this->callback(function($param) use ($expected) {
        $this->assertEquals($param, $expected[$matcher->getInvocationCount() - 1]);
   return true;
   })
)

1 Comment

For two large objects - we have simple message: objects are not equals, without diff and without any info.
5

I think, the willReturnMap could be also a useful alternative.

$mock = $this->createMock(MyClass::class):

$mock->expects(self::exactly(3))
     ->method('get')
     ->willReturnMap([
         [1, 'Foo'],
         [9, 'Bar'],
         [5, 'Baz'],
     ]);

self::assertSame('Bar', $mock->get(9));
self::assertSame('Baz', $mock->get(5));
self::assertSame('Foo', $mock->get(1));

Note that the invocation order is not going to be defined by the map you pass.

So if the invocation order is not important for you, I think this is the least "noisy" solution.

3 Comments

Exactly order of invocation was important for me. But thanks for solution.
Well in that case you have around with the invocation counter. But let me point out, that it's not a really good approach to expect an order for the invocation.
Thanks for the solution, it's exacly what I was looking for. Simple and working :)
3

I ran into the same issue and although I don't think this is the most practical solution in the world, you can try it.

You will need a simple helper function.

public function consecutiveCalls(...$args): callable
{
    $count = 0;
    return function ($arg) use (&$count, $args) {
        return $arg === $args[$count++];
    };
}

Then we'll replace deprecated withConsecutive with with and for every parameter we'll add callback that will return our helper function with consecutive parameters.

$this->personServiceMock->expects($this->exactly(2))
    ->method('prepare')
    ->with(
        self::callback(self::consecutiveCalls($personFirst, $personSecond)),
        self::callback(self::consecutiveCalls($employeeFirst, $employeeSecond)),
    )
    ->willReturnOnConsecutiveCalls($personDTO, $personSecondDTO);

1 Comment

Nice shortcut! thank you
3

@awais-mushtaq thanks for your solution: https://stackoverflow.com/a/75686291/11990794 :

$matcher = $this->exactly(2);
$this->service
    ->expects($matcher)
    ->method('functionName')
    ->willReturnCallback(function (string $key, string $value) use ($matcher,$expected1, $expected2) {
        match ($matcher->numberOfInvocations()) {
            1 =>  $this->assertEquals($expected1, $value),
            2 =>  $this->assertEquals($expected2, $value),
        };
    });

I improved it a bit and adjusted it to the code in the questioner's post

$expected = [
    1 => [
        'person' => $personFirst,
        'employee' => $employeeFirst,
        'return' => $personDTO,
    ],
    2 => [
        'person' => $personSecond,
        'employee' => $employeeSecond,
        'return' => $personSecondDTO,
    ],
];
$matcher = $this->exactly(count($expected));
$this->personServiceMock
    ->expects($matcher)
    ->method('prepare')
    ->willReturnCallback(function ($person, $employee) use ($matcher, $expected) {
        $callNr = $matcher->numberOfInvocations();
        $this->assertSame($expected[$callNr]['person'], $person);
        $this->assertSame($expected[$callNr]['employee'], $employee);

        return $expected[$callNr]['return'];
    });

Comments

2

There are around ~10 variations of how PHPUnit withConsecutive() can be upgraded. If there is a reference, followed by willReturn*() or by willReturnOnConsecutive().

Instead of creating answer that fits only one specific quesition, I've put down an article that explains, how to upgrade all kind of combinations.

See: https://tomasvotruba.com/blog/how-to-upgrade-deprecated-phpunit-with-consecutive

Hopefully you'll find it useful.

It includes PHP 7.* ready code (no match), that can be used in PHPUnit 9 (that requires 7.3+)

Comments

0

Looks like there are not exists solution from the box. So, what I found - several solutions

  1. Use your own trait which implements method withConsecutive
  2. Use prophecy or mockery for mocking.

2 Comments

What's the alternative using Mockery?
Love the trait. Implemented my own and wanted to share, but the solution in the link is fine to use.
0

I have created the factory for a callback passed to ->willReturnCallback() PHPUnit method and it goes like this (inspiration: @Awais Mushtaq):

protected function getReturnCallbackFn(
    InvocationOrder $matcher,
    array $paramConsuitiveCalls,
    array $returnConsuitiveCalls
): \Closure
{
    if (!empty($returnConsuitiveCalls) && count($paramConsuitiveCalls) !== count($returnConsuitiveCalls)) {
        throw new \InvalidArgumentException('Count of params and return values mismatch.');
    }
    return function (...$args) use (
        $matcher,
        $paramConsuitiveCalls,
        $returnConsuitiveCalls
    ) {
        $i = $matcher->numberOfInvocations() - 1;
        if (!array_key_exists($i, $paramConsuitiveCalls)) {
            throw new \OutOfRangeException(sprintf(
                'Iterations expected [%d] against current [%d] executed.',
                count($returnConsuitiveCalls),
                $matcher->numberOfInvocations()),
            );
        }
        if (empty($args)) {
            $this->assertEquals($paramConsuitiveCalls[$i], []);
        } else {
            foreach ($args as $argI => $arg) {
                $this->assertEquals($paramConsuitiveCalls[$i][$argI], $arg);
            }
        }
        if (empty($returnConsuitiveCalls)) {
            return;
        }
        return $returnConsuitiveCalls[$i];
    };
}

And usage:

$params = [[123], [234]];
$ret = [$sampleData1Call, $sampleData2Call];
$matcher = $this->exactly(count($params));
$stub
    ->method('getById')
    ->willReturnCallback($this->getReturnCallbackFn($matcher, $params, $ret))
;

Comments

-1

We have a large codebase and used withConsecutive frequently. To avoid having to fix every test we created a phpunit-extensions package to ease the transition.

The notation should be fairly easy to find and replace existing usages:
$mock->method('myMethod')->withConsecutive([123, 'foobar'], [456]);

To:
$mock->method('myMethod')->with(...\DR\PHPUnitExtensions\Mock\consecutive([123, 'foobar'], [456]));

It's even easier with PHPStorm's structural search and replace: https://www.jetbrains.com/help/phpstorm/structural-search-and-replace.html

Comments

-1

Another approach involves capturing the arguments passed to the function using an anonymous function and a reference-passed array, then performing assertions afterwards.

Consider the following example:

$persistedProducts = [];
$entityManager->expects(self::exactly(2))
    ->method('persist')
    ->with(
        self::callback(
            static function (ProductInterface $product) use (&$persistedProducts) {
                $persistedProducts[] = $product;

                return true;
            }
        )
    );


// Execute the code under test here
// e.g. new ProductUpdater($em)->update();


// First persisted product is the one that was returned from mock repository,
// so we can compare identically.
self::assertSame($product123, $persistedProducts[0]);

// Second persisted product is a new product, we need to check its attributes
self::assertSame(456, $persistedProducts[1]->getId());
self::assertSame(73, $persistedProducts[1]->getSold());

In this case, since persist() returns void, I chose not to use willReturnCallback. While it's possible to apply the same method in this context, it's not semantically ideal. This is because you're not primarily concerned with the return value in this instance - although you might be in subsequent uses of with or assert statements.

Comments

-1

Another solution:

$mock->method('run')->with($this->callback(function (string $arg) {
            static $i = 0;
            [
                1 => function () use ($arg) {$this->assertEquals('this', $arg);},
                2 => function () use ($arg) {$this->assertEquals('that', $arg);},
            ][++$i]();
            return true;
        }))

Also:

    $mock->method('run')->with($this->callback(function (string $arg) {
        $inputs = ['this', 'that'];
        static $i = -1;
        $this->assertEquals($inputs[++$i], $arg);
        return true;
    }));

Comments

-2
$params = ['foo', 'bar',];
$mockObject->expects($this->exactly(2))
    ->method('call')
    ->willReturnCallback(function (string $param) use (&$params) {
        $this::assertSame(\array_shift($params), $param);
    })
    ->willReturnOnConsecutiveCalls('foo_result', 'bar_result');

Or instead of using willReturnOnConsecutiveCalls you can return result from willReturnCallback

Comments

-2

For our applications we use our custom constraint with specific map. Previously we try to use callback (with call assertEquals inside), but callback must return only boolean and if we try to check objects, the message was be simple - objects are not equals, without diff and without any information about problem.

As result, we create our constaint:

<?php

declare(strict_types = 1);

namespace Acme\Tests\PhpUnit\Framework\Constraint;

use PHPUnit\Framework\Constraint\Constraint;
use PHPUnit\Framework\MockObject\Rule\InvocationOrder;
use SebastianBergmann\Comparator\ComparisonFailure;
use SebastianBergmann\Comparator\Factory;

class ConsecutiveMatches extends Constraint
{
    /**
     * @var ComparisonFailure|null
     */
    private ?ComparisonFailure $failure = null;

    /**
     * Constructor.
     *
     * @param InvocationOrder   $invocation
     * @param array<int, mixed> $map
     * @param bool              $strict
     */
    public function __construct(
        private readonly InvocationOrder $invocation,
        private readonly array           $map,
        private readonly bool            $strict = true,
    ) {
    }

    /**
     * {@inheritdoc}
     */
    protected function matches(mixed $other): bool
    {
        $invokedCount = $this->invocation->numberOfInvocations();

        if (\array_key_exists($invokedCount - 1, $this->map)) {
            $expectedParam = $this->map[$invokedCount - 1];
        } else if ($this->strict) {
            throw new \InvalidArgumentException(\sprintf(
                'Missed argument for matches (%d times).',
                $invokedCount
            ));
        }

        $comparator = Factory::getInstance()->getComparatorFor($expectedParam, $other);

        try {
            $comparator->assertEquals($expectedParam, $other);
        } catch (ComparisonFailure $error) {
            $this->failure = $error;

            return false;
        }

        return true;
    }

    /**
     * {@inheritdoc}
     */
    protected function failureDescription(mixed $other): string
    {
        return $this->failure ? $this->failure->getDiff() : parent::failureDescription($other);
    }

    /**
     * {@inheritdoc}
     */
    public function toString(): string
    {
        return '';
    }
}

In this constraint we get the argument from map by invocation number.

And it very easy to usage:

#[Test]
public function shouldFoo(): void
{
    $mock = $this->createMock(MyClass::class);

    $matcher = new InvokedCount(2); // Should call 2 times

    $mock->expects($matcher)
        ->method('someMethod')
        ->with(new ConsecutiveMatches($matcher, [$expectedArgumentForFirstCall, $expectedArgumentForSecondCall]))
        ->willReturnCallback(function () {
            // You logic for return value.
            // You can use custom map too for returns.
        });
}

As result, we can use our constraint in more places.

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.