diff --git a/public/assets/images/posts/2024/with-consecutive.png b/public/assets/images/posts/2024/with-consecutive.png new file mode 100644 index 00000000000..901bcaea339 Binary files /dev/null and b/public/assets/images/posts/2024/with-consecutive.png differ diff --git a/resources/css/app.scss b/resources/css/app.scss index a0b5b49c7a3..64d1520a51e 100644 --- a/resources/css/app.scss +++ b/resources/css/app.scss @@ -152,6 +152,11 @@ img { } #post { + h1 { + // magic :) + text-wrap: balance; + } + img { text-align: center; margin: .5em auto 1.5em auto; diff --git a/resources/posts/2024/2024-10-08-how-to-upgrade-deprecated-phpunit-with-consecutive.md b/resources/posts/2024/2024-10-08-how-to-upgrade-deprecated-phpunit-with-consecutive.md new file mode 100644 index 00000000000..667311a026b --- /dev/null +++ b/resources/posts/2024/2024-10-08-how-to-upgrade-deprecated-phpunit-with-consecutive.md @@ -0,0 +1,231 @@ +--- +id: 417 +title: "How to Upgrade deprecated PHPUnit withConsecutive()" +perex: | + The `withConsecutive()` method [was deprecated in PHPUnit 9](https://github.com/sebastianbergmann/phpunit/issues/4255#issuecomment-636422439) and removed in PHPUnit 10. It sparked many [questions](https://stackoverflow.com/questions/75389000/replace-phpunit-method-withconsecutive-abandoned-in-phpunit-10), [on StackOverflow](https://stackoverflow.com/questions/77865216/phpunit-withconsecutive-is-gone-what-is-the-recommended-approach), in [various projets](https://www.drupal.org/project/drupal/issues/3306554) and [GitHub](https://github.com/search?q=repo%3Asebastianbergmann%2Fphpunit+withConsecutive&type=issues). + + It was not very popular BC break. There is no 1:1 replacement. It can be combined with `willReturn*()` methods and that can make it even more tricky to merge with. + + PHPUnit upgrades take 95 % of time just to upgrade this single method, and 5 % about everything else. + Recent months we've done couple of project upgrades with Rector and we've learned a lot. + + Today, I want to share bit of knowledge with you and explain, **why it's a change for better code**. +--- + +What does `withConsecutive()` method actually do? + +```php +$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: + +* on 1st call, it expects `['first']` +* on 2nd call, it expects `['second']` + +To be honest, I've never wrote such code myself, but so far we 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 be replace it? It would be very convenient, if there would some kind of `withNthCall()` method: + +```php +$mock = $this->createMock(MyClass::class); +$mock->expects($this->exactly(2)) + ->method('someMethod') + ->withNthCall(1, ['first']) + ->withNthCall(2, ['second']); +``` + +But it's not. + + +## `withCallable()` to the Rescue + +Instead we use `withCallable()` trick. This methods accepts the called parameters, that we can assert inside. + +```php +$mock = $this->createMock(MyClass::class); +$mock->expects($this->exactly(2)) + ->method('someMethod') + ->withCallable(function ($parameters) { + // check the parameters here + }); + ``` + +But how do we detect, if it's the 1st or 2nd call? The `$this->exactly(2)` expression actually returns a value object `PHPUnit\Framework\MockObject\Rule\InvokedCount` that we can work with. + +```php +$invokedCount = $this->exactly(2); + +$mock = $this->createMock(MyClass::class); +$mock->expects($invokedCount) + ->method('someMethod') + ->withCallable(function ($parameters) use ($invokedCount) { + // check the parameters here + }); +``` + +On every method mock invoke, the number of invokes in `$invokedCount` will get increased. + +
+ +We can use it to detect the 1st or 2nd call: + +```php +$invokedCount = $this->exactly(2); + +$mock = $this->createMock(MyClass::class); +$mock->expects($invokedCount) + ->method('someMethod') + ->withCallable(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 original parameters we needed it: + +```php +// ... + + ->withCallable(function ($parameters) use ($invokedCount) { + if ($invokedCount->getInvocationCount() === 1) { + $this->assertSame(['first'], $parameters); + } + + if ($invokedCount->getInvocationCount() === 2) { + $this->assertSame(['second'], $parameters); + } + }); +``` + +Now this where this deprecation becomes useful. What if one of parameters is an product object? + +We could create a `$product` object and do `assertSame()`. But what if we only care about its price? + +```php +// ... + ->withCallable(function ($parameters) use ($invokedCount) { + if ($invokedCount->getInvocationCount() === 1) { + $product = $parameter[0]; + $this->assertInstanceof(Product::class, $product); + $this->assertSame(100, $product->getPrice()); + } + + // ... + }); +``` + +This would turn into single-line mess using `withConsecutive()`. Now it's more readable and flexible. + +
+ +## Why `if` over `match`? + +Originally, we used `match()` expression over `ifs()` in Rector rule, but it created couple of new problems: + +* PHPUnit 9.x requires PHP 7.3+. Using `match()` would mean you have to do the upgrade to PHP 8 and to PHPUnit 10 at the same time. This is not always possible and can be risky +* The call count is already checked by `$this->exactly(2)`. There is no need to add another layer of complexity to check the same thing again +* With `match()` there is only single line of expression. Assert above would be single line: + +```php +=> $product = $parameters[0] && $this->assertInstanceof(Product::class, $product) && $this->assertSame(100, $product->getPrice()) + ``` + +Which 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 return some value. That's where `willReturn*()` methods come into play: + +```php +$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: + +```php +// ... + + ->withCallable(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? + +```php +->willReturnArgument(0); +// or +->willReturnSelf(); +// or +->willThrowException(new \Exception('Never happens')); +``` + +We can just write plain PHP code: + +```php +// ... + + ->withCallable(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 + +* We don't have to learn special PHPUnit mock method naming and we can understand the code. +* This vanilla PHP also opens up next step - refactoring [away from mocks to anonymous typed classes](/blog/2018/06/11/how-to-turn-mocks-from-nightmare-to-solid-kiss-tests) +* We can easily add new assertion line +* We can return values we need + + +What if in upcoming PHPUnit 12, 13, 14... versions some of mocking methods will be changed or removed? This code will most likely work, as it's just plain PHP. + +
+ +This is how we can upgrade `withConsecutive()` method in PHPUnit 9 or earlier. I hope it's more clear now why this change was needed, and how it can help you write better tests. + +
+ +Last but not least, here is [the Rector rule](https://getrector.com/rule-detail/with-consecutive-rector) that automated this process. + +
+ + +Happy coding!