How to Upgrade deprecated PHPUnit withConsecutive()

The withConsecutive() method was deprecated in PHPUnit 9 and removed in PHPUnit 10. It sparked many questions, on StackOverflow, in various projets and GitHub.

It was not a very popular BC break. There is no 1:1 replacement. It can be combined with willReturn*() methods, which can make it even more tricky to merge with.

PHPUnit upgrades take 95 % of the time to upgrade this single method and 5 % for everything else. In recent months, we've done a couple of project upgrades with Rector and learned a lot.

Today, I want to share some knowledge with you and explain why it's a change for better code.

What does withConsecutive() method actually do?

$mock = $this->createMock(MyClass::class);
$mock->expects($this->exactly(2))
    ->method('someMethod')
    ->withConsecutive(
        ['first'],
        ['second']
    );

It defines what arguments are on the input once the method mock is called. E.g. here:

To be honest, I've never written such code myself, but so far, we've found it in every code base we've upgraded. It's been available since 2006 and only removed after 16 years in 2022.


So how can we replace it? It would be very convenient if there would some kind of withNthCall() method:

$mock = $this->createMock(MyClass::class);
$mock->expects($this->exactly(2))
    ->method('someMethod')
    ->withNthCall(1, ['first'])
    ->withNthCall(2, ['second']);

But it's not.

willReturnCallback() to the Rescue

Instead, we use the willReturnCallback() trick. This method accepts the called parameters, which we can assert inside.

$mock = $this->createMock(MyClass::class);
$mock->expects($this->exactly(2))
    ->method('someMethod')
    ->willReturnCallback(function ($parameters) {
        // check the parameters here
    });

But how do we detect if it's the first or second call? The $this->exactly(2) expression actually returns a value object PHPUnit\Framework\MockObject\Rule\InvokedCount that we can work with.

$invokedCount = $this->exactly(2);

$mock = $this->createMock(MyClass::class);
$mock->expects($invokedCount)
    ->method('someMethod')
    ->willReturnCallback(function ($parameters) use ($invokedCount) {
        // check the parameters here
    });

On every mock invoke method, the number of invokes in $invokedCount will increase.


We can use it to detect the 1st or 2nd call:

$invokedCount = $this->exactly(2);

$mock = $this->createMock(MyClass::class);
$mock->expects($invokedCount)
    ->method('someMethod')
    ->willReturnCallback(function ($parameters) use ($invokedCount) {
        if ($invokedCount->getInvocationCount() === 1) {
            // check the 1st round here
        }

        if ($invokedCount->getInvocationCount() === 2) {
            // check the 2nd round here
        }
    });

Now we include the original parameters we needed:

// ...

    ->willReturnCallback(function ($parameters) use ($invokedCount) {
        if ($invokedCount->getInvocationCount() === 1) {
            $this->assertSame(['first'], $parameters);
        }

        if ($invokedCount->getInvocationCount() === 2) {
            $this->assertSame(['second'], $parameters);
        }
    });

Now, this is where this deprecation becomes useful. What if one of the parameters is a product object?

We could create a $product object and do assertSame(). But what if we only care about its price?

// ...
    ->willReturnCallback(function ($parameters) use ($invokedCount) {
        if ($invokedCount->getInvocationCount() === 1) {
            $product = $parameter[0];
            $this->assertInstanceof(Product::class, $product);
            $this->assertSame(100, $product->getPrice());
        }

        // ...
    });

Using withConsecutive() would turn this into a single-line mess. Now, it's more readable and flexible.


Why if over match?

Originally, we used the match() expression over ifs() in the Rector rule, but it created a couple of new problems:

=> $product = $parameters[0] && $this->assertInstanceof(Product::class, $product) && $this->assertSame(100, $product->getPrice())

This code is not readable and maintainable. There is also one more reason why if() is the king.

Return value

More often than not, the method not only accepts parameters but also returns some value. That's where willReturn*() methods come into play:

$mock = $this->createMock(MyClass::class);
$mock->expects($this->exactly(2))
    ->method('someMethod')
    ->withConsecutive(
        ['first'],
        ['second']
    )
    ->willReturnOnConsecutiveCalls([1, 2]);

How do we upgrade any returned value? We just return it:

// ...

    ->willReturnCallback(function ($parameters) use ($invokedCount) {
        if ($invokedCount->getInvocationCount() === 1) {
            $this->assertSame(['first'], $parameters);
            return 1;
        }

        if ($invokedCount->getInvocationCount() === 2) {
            $this->assertSame(['second'], $parameters);
            return 2;
        }
    });

That's it! How about other return methods?

->willReturnArgument(0);
// or
->willReturnSelf();
// or
->willThrowException(new \Exception('Never happens'));

We can just write plain PHP code:

// ...

    ->willReturnCallback(function ($parameters) use ($invokedCount) {
        if ($invokedCount->getInvocationCount() === 1) {
            return $parameters[0];
            // or
            return $this->userServiceMock;
            // or
            throw new Exception('Never happens');
        }
    });

More Readable and Easier to Maintain


What if upcoming PHPUnit 12, 13... versions, removes or changes more mocking methods? This code will work, as it's just plain PHP.


This is how we can upgrade the withConsecutive() method in PHPUnit 9 or earlier. I hope it's clearer now why this change was needed and how it can help you write better tests.


Last but not least, here is the Rector rule that automated this process.


Next Upgrade in PHPUnit 10

In PHPUnit 10, the getInvocationCount() got renamed to numberOfInvocations(). Make sure you upgrade the method name, once you go to PHPUnit 10:

-if ($invokedCount->getInvocationCount() === 1) {
+if ($invokedCount->numberOfInvocations() === 1) {
     $this->assertSame(['first'], $parameters);
     return 1;
 }

Happy coding!




Do you learn from my contents or use open-souce packages like Rector every day?
Consider supporting it on GitHub Sponsors. I'd really appreciate it!