Replace PHPUnit method `withConsecutive` (abandoned in PHPUnit 10)
Asked Answered
F

13

34

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

Florescence answered 8/2, 2023 at 16:44 Comment(3)
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.Thionic
@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.Sorcim
Bergmann is well known to be ignorant. He even chooses random political views to print them into user terminals and abusing fact that many people rely on his free library.Sudderth
L
40

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),
        };
    });
Larrisa answered 9/3, 2023 at 14:56 Comment(7)
This solution simply works! Thank a lot for sharingEyeshot
I've used your answer to create a Rector rule to automate this upgrade: github.com/rectorphp/rector-phpunit/pull/246 Thank youEyeshot
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 :)Larrisa
@TomasVotruba Might make sense to mention Awais as an author in the commit, no? 😊Tripetalous
@Tripetalous I've linked it right in the PR github.com/rectorphp/rector-phpunit/pull/246, hope that's good enough :)Eyeshot
@AwaisMushtaq That¨s awesome to hear, full circle :), let me know in Github if you need any help with Rector or have a rule suggestions. I'd be happy to helpEyeshot
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/…Latinity
P
14

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);
Pectoralis answered 15/2, 2023 at 16:36 Comment(2)
This is good, but your solution doesn't count the order of running methods.Florescence
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].Lynnell
M
6

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;
   })
)
Motmot answered 26/4, 2023 at 12:41 Comment(1)
For two large objects - we have simple message: objects are not equals, without diff and without any info.Enplane
D
4

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.

Decagram answered 24/11, 2023 at 8:50 Comment(3)
Exactly order of invocation was important for me. But thanks for solution.Florescence
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.Worn
Thanks for the solution, it's exacly what I was looking for. Simple and working :)Eyeshot
C
2

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);
Confectionery answered 22/2, 2023 at 17:9 Comment(0)
M
1

@awais-mushtaq thanks for your solution: https://mcmap.net/q/339191/-replace-phpunit-method-withconsecutive-abandoned-in-phpunit-10 :

$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'];
    });
Monamonachal answered 24/4 at 19:21 Comment(0)
F
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.
Florescence answered 9/2, 2023 at 7:54 Comment(2)
What's the alternative using Mockery?Sorcim
Love the trait. Implemented my own and wanted to share, but the solution in the link is fine to use.Lymphangial
I
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))
;
Indocile answered 27/9, 2023 at 20:39 Comment(0)
A
-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

Appendix answered 29/7, 2023 at 14:29 Comment(0)
A
-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;
    }));
Ardent answered 23/11, 2023 at 15:38 Comment(0)
K
-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.

Kristinkristina answered 26/11, 2023 at 14:7 Comment(0)
E
-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

Erlineerlinna answered 24/5, 2023 at 14:22 Comment(0)
E
-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.

Enplane answered 18/8, 2023 at 11:51 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.