From 477598cda0026b0c01435c13201695a9e01ae96b Mon Sep 17 00:00:00 2001 From: Tomas Votruba Date: Wed, 12 Nov 2025 14:36:25 +0100 Subject: [PATCH 1/2] [post] phpunit cq --- composer.json | 18 +- composer.lock | 70 ++-- rector.php | 1 + ...-make-phpunit-tests-perfect-in-15-diffs.md | 349 ++++++++++++++++++ 4 files changed, 394 insertions(+), 44 deletions(-) create mode 100644 resources/blog/posts/2025/2025-11-12-make-phpunit-tests-perfect-in-15-diffs.md diff --git a/composer.json b/composer.json index 2bcfee1ef..d874f3119 100644 --- a/composer.json +++ b/composer.json @@ -6,12 +6,12 @@ "php": "^8.4", "imagine/imagine": "^1.5", "jajo/jsondb": "^3.0.1", - "laravel/framework": "^12.20", + "laravel/framework": "^12.37", "league/commonmark": "^2.7", "livewire/livewire": "^3.6.3", "nesbot/carbon": "^3.10.1", - "nikic/php-parser": "^5.6.1", - "rector/rector": "dev-main as 2.0", + "nikic/php-parser": "^5.6.2", + "rector/rector": "dev-main as 2.2", "samsonasik/array-lookup": "^2.0.3", "symfony/filesystem": "^7.3", "symfony/uid": "^7.3.1", @@ -19,16 +19,16 @@ "symplify/vendor-patches": "^11.5" }, "require-dev": { - "barryvdh/laravel-ide-helper": "^3.5.5", - "driftingly/rector-laravel": "^2.0.5", - "nette/robot-loader": "^4.0.3", - "phpecs/phpecs": "^2.1.3", + "barryvdh/laravel-ide-helper": "^3.6", + "driftingly/rector-laravel": "^2.1", + "nette/robot-loader": "^4.1", + "phpecs/phpecs": "^2.2", "phpstan/extension-installer": "^1.4.3", - "phpstan/phpstan": "^2.1.17", + "phpstan/phpstan": "^2.1.32", "phpstan/phpstan-webmozart-assert": "^2.0", "phpunit/phpunit": "11.4", "rector/jack": "^0.4", - "rector/swiss-knife": "^2.3.1", + "rector/swiss-knife": "^2.3.3", "tomasvotruba/class-leak": "^2.0.5" }, "autoload": { diff --git a/composer.lock b/composer.lock index 5ebbed90a..ab76aacc7 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "f60a7a2b81a956f35d3fa40c60e69757", + "content-hash": "878a460f74c3416a0d38c3a9a61f915d", "packages": [ { "name": "brick/math", @@ -1741,16 +1741,16 @@ }, { "name": "league/flysystem", - "version": "3.30.1", + "version": "3.30.2", "source": { "type": "git", "url": "https://github.com/thephpleague/flysystem.git", - "reference": "c139fd65c1f796b926f4aec0df37f6caa959a8da" + "reference": "5966a8ba23e62bdb518dd9e0e665c2dbd4b5b277" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/c139fd65c1f796b926f4aec0df37f6caa959a8da", - "reference": "c139fd65c1f796b926f4aec0df37f6caa959a8da", + "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/5966a8ba23e62bdb518dd9e0e665c2dbd4b5b277", + "reference": "5966a8ba23e62bdb518dd9e0e665c2dbd4b5b277", "shasum": "" }, "require": { @@ -1818,22 +1818,22 @@ ], "support": { "issues": "https://github.com/thephpleague/flysystem/issues", - "source": "https://github.com/thephpleague/flysystem/tree/3.30.1" + "source": "https://github.com/thephpleague/flysystem/tree/3.30.2" }, - "time": "2025-10-20T15:35:26+00:00" + "time": "2025-11-10T17:13:11+00:00" }, { "name": "league/flysystem-local", - "version": "3.30.0", + "version": "3.30.2", "source": { "type": "git", "url": "https://github.com/thephpleague/flysystem-local.git", - "reference": "6691915f77c7fb69adfb87dcd550052dc184ee10" + "reference": "ab4f9d0d672f601b102936aa728801dd1a11968d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/flysystem-local/zipball/6691915f77c7fb69adfb87dcd550052dc184ee10", - "reference": "6691915f77c7fb69adfb87dcd550052dc184ee10", + "url": "https://api.github.com/repos/thephpleague/flysystem-local/zipball/ab4f9d0d672f601b102936aa728801dd1a11968d", + "reference": "ab4f9d0d672f601b102936aa728801dd1a11968d", "shasum": "" }, "require": { @@ -1867,9 +1867,9 @@ "local" ], "support": { - "source": "https://github.com/thephpleague/flysystem-local/tree/3.30.0" + "source": "https://github.com/thephpleague/flysystem-local/tree/3.30.2" }, - "time": "2025-05-21T10:34:19+00:00" + "time": "2025-11-10T11:23:37+00:00" }, { "name": "league/mime-type-detection", @@ -2761,11 +2761,11 @@ }, { "name": "phpstan/phpstan", - "version": "2.1.31", + "version": "2.1.32", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/ead89849d879fe203ce9292c6ef5e7e76f867b96", - "reference": "ead89849d879fe203ce9292c6ef5e7e76f867b96", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/e126cad1e30a99b137b8ed75a85a676450ebb227", + "reference": "e126cad1e30a99b137b8ed75a85a676450ebb227", "shasum": "" }, "require": { @@ -2810,7 +2810,7 @@ "type": "github" } ], - "time": "2025-10-10T14:14:11+00:00" + "time": "2025-11-11T15:18:17+00:00" }, { "name": "psr/clock", @@ -3428,12 +3428,12 @@ "source": { "type": "git", "url": "https://github.com/rectorphp/rector.git", - "reference": "5f0de3ac4e015c4ba0a97d7a9ce84dc44aafc331" + "reference": "2c3da2b63daa748ae4b4df1df9f5fb5b9cf0a232" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/rectorphp/rector/zipball/5f0de3ac4e015c4ba0a97d7a9ce84dc44aafc331", - "reference": "5f0de3ac4e015c4ba0a97d7a9ce84dc44aafc331", + "url": "https://api.github.com/repos/rectorphp/rector/zipball/2c3da2b63daa748ae4b4df1df9f5fb5b9cf0a232", + "reference": "2c3da2b63daa748ae4b4df1df9f5fb5b9cf0a232", "shasum": "" }, "require": { @@ -3481,7 +3481,7 @@ "type": "github" } ], - "time": "2025-11-10T02:07:17+00:00" + "time": "2025-11-11T11:26:23+00:00" }, { "name": "samsonasik/array-lookup", @@ -4238,16 +4238,16 @@ }, { "name": "symfony/http-foundation", - "version": "v7.3.6", + "version": "v7.3.7", "source": { "type": "git", "url": "https://github.com/symfony/http-foundation.git", - "reference": "6379e490d6ecfc5c4224ff3a754b90495ecd135c" + "reference": "db488a62f98f7a81d5746f05eea63a74e55bb7c4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-foundation/zipball/6379e490d6ecfc5c4224ff3a754b90495ecd135c", - "reference": "6379e490d6ecfc5c4224ff3a754b90495ecd135c", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/db488a62f98f7a81d5746f05eea63a74e55bb7c4", + "reference": "db488a62f98f7a81d5746f05eea63a74e55bb7c4", "shasum": "" }, "require": { @@ -4297,7 +4297,7 @@ "description": "Defines an object-oriented layer for the HTTP specification", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-foundation/tree/v7.3.6" + "source": "https://github.com/symfony/http-foundation/tree/v7.3.7" }, "funding": [ { @@ -4317,20 +4317,20 @@ "type": "tidelift" } ], - "time": "2025-11-06T11:05:57+00:00" + "time": "2025-11-08T16:41:12+00:00" }, { "name": "symfony/http-kernel", - "version": "v7.3.6", + "version": "v7.3.7", "source": { "type": "git", "url": "https://github.com/symfony/http-kernel.git", - "reference": "f9a34dc0196677250e3609c2fac9de9e1551a262" + "reference": "10b8e9b748ea95fa4539c208e2487c435d3c87ce" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-kernel/zipball/f9a34dc0196677250e3609c2fac9de9e1551a262", - "reference": "f9a34dc0196677250e3609c2fac9de9e1551a262", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/10b8e9b748ea95fa4539c208e2487c435d3c87ce", + "reference": "10b8e9b748ea95fa4539c208e2487c435d3c87ce", "shasum": "" }, "require": { @@ -4415,7 +4415,7 @@ "description": "Provides a structured process for converting a Request into a Response", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-kernel/tree/v7.3.6" + "source": "https://github.com/symfony/http-kernel/tree/v7.3.7" }, "funding": [ { @@ -4435,7 +4435,7 @@ "type": "tidelift" } ], - "time": "2025-11-06T20:58:12+00:00" + "time": "2025-11-12T11:38:40+00:00" }, { "name": "symfony/mailer", @@ -8884,8 +8884,8 @@ { "package": "rector/rector", "version": "dev-main", - "alias": "2.0", - "alias_normalized": "2.0.0.0" + "alias": "2.2", + "alias_normalized": "2.2.0.0" } ], "minimum-stability": "stable", diff --git a/rector.php b/rector.php index 0be79a73e..4645ec03e 100644 --- a/rector.php +++ b/rector.php @@ -22,6 +22,7 @@ ) ->withPhpSets() ->withAttributesSets() + ->withComposerBased(symfony: true, twig: true, laravel: true, doctrine: true, phpunit: true, netteUtils: true) ->withSkip([ RenameForeachValueVariableToMatchMethodCallReturnTypeRector::class => [ // metadata -> datum false positive diff --git a/resources/blog/posts/2025/2025-11-12-make-phpunit-tests-perfect-in-15-diffs.md b/resources/blog/posts/2025/2025-11-12-make-phpunit-tests-perfect-in-15-diffs.md new file mode 100644 index 000000000..7b7528024 --- /dev/null +++ b/resources/blog/posts/2025/2025-11-12-make-phpunit-tests-perfect-in-15-diffs.md @@ -0,0 +1,349 @@ +--- +id: 81 +title: "Make PHPUnit tests Perfect in 15 Diffs" +perex: | + + Rector helps you improve PHP code, upgrade it to latest PHP version, make use of modern features and faster code structures. But did you know it can make your PHPUnit tests faster and easier to read? + + New PHPUnit version have more precise and reliable asserts, but most people don't know about them. They make tests run faster and in case of failure, provide more clear error message you'll understand. + + Rector can help you with that! +--- + +There are two main ways to keep your PHPUnit tests up-to-date and in perfect shape without any work. + +## First: Use latest PHPUnit features + +A year ago we introduced [Composer-version based sets](/blog/introducing-composer-version-based-sets). This feature: + +* tells Rector to read your `composer.json`, +* detect your installed PHPUnit version +* and automatically pick up the sets that handle the upgrade to new features + +
+ +To enable it, just add this line to your `rector.php` config: + +```php +use Rector\Config\RectorConfig; + +return RectorConfig::configure() + ->withComposerBased(phpunit: true); +``` + + +
+ +Setup once and forget. That's it. No need to list sets everytime new PHPUnit version is out. Rector now automatically applies PHPUnit upgrade sets, everytime you change version in `composer.json`: + +```diff + { + "require-dev": { +- "phpunit/phpunit": "^12.5" ++ "phpunit/phpunit": "^13.0" + } +} +``` + +
+ +Run composer to install the new PHPUnit version. Then Rector to apply the upgrade: + +```bash +composer update +vendor/bin/rector +``` + +
+ +That's it! + + +
+ +## Second: Use the best PHPUnit asserts and practices + +Using new feature is first step to perfection, but there is more. Less code is better, and more precise assertion is better. But PHPUnit has so many different asserts, it's hard to keep up with the best ones to use in that particular situation. + +
+ +### 1. Exact Assertions + +In prehistorical past, there was only `assertTrue()` and `assertFalse()`. Now, PHPUnit has many more precise assertions you can use: + +```diff +-$this->assertTrue(isset($anything["foo"]), "message"); ++$this->assertArrayHasKey("foo", $anything, "message"); +``` + +
+ +```diff +-$this->assertTrue(property_exists(new Class, "property")); ++$this->assertClassHasAttribute("property", "Class"); +``` + +
+ +```diff +-$this->assertSame(true, $value); ++$this->assertTrue($value); +``` + +
+ +### 2. More human-readable mocks + + +```diff +- ->willReturnCallback(function (...$parameters) use ($matcher) { +- match ($matcher->getInvocationCount()) { +- 1 => $this->assertSame([1], $parameters), +- }; +- }); + ++ ->with(1, $parameters); +``` + +
+ +```diff + $translator = $this->createMock('SomeClass'); + $translator->expects($this->any()) + ->method('trans') +- ->will($this->returnValue('translated max {{ max }}!')); ++ ->willReturnValue('translated max {{ max }}!'); +``` + +
+ +```diff + $this->createMock('SomeClass') + ->method('someMethod') +- ->with($this->callback(function (array $args): bool { +- return true; +- })) +- ->willReturn(['some item']); ++ ->willReturnCallback(function (array $args): array { ++ return ['some item']; ++ }); + ``` + +
+ +```diff +-->willReturnCallback(function (...$parameters) use ($matcher) { +- match ($matcher->getInvocationCount()) { +- 1 => $this->assertSame([1], $parameters), +- }; +-}); ++ ->with(1, $parameters); +``` + +
+ +Why mocks property for a whole tests, if it's used only once? + +```diff + use PHPUnit\Framework\TestCase; + + class SomeServiceTest extends TestCase + { +- private $someServiceMock; +- + public function setUp(): void + { +- $this->someServiceMock = $this->createMock(SomeService::class); ++ $someServiceMock = $this->createMock(SomeService::class); + } + } +``` + +
+ +### 3. Correct Type Declarations + +```diff + use PHPUnit\Framework\TestCase; + + class SomeTest extends TestCase + { + public function test() + { + $someClass = new SomeClass(); +- $someClass->setPhone(12345); ++ $someClass->setPhone('12345'); + } + } + + final class SomeClass + { + public function setPhone(string $phone) + { + } + } +``` + +
+ + +```diff + use PHPUnit\Framework\TestCase; + + final class SomeTest extends TestCase + { + public function test(): \stdClass + { + return new \stdClass(); + } + + /** + * @depends test + */ +- public function testAnother($someObject) ++ public function testAnother(\stdClass $someObject) + { + } + } +``` + +
+ +There is no better way to start using strict types, than in tests. The safest way to spot, which places use loose types: + +```diff ++declare(strict_types=1); ++ + use PHPUnit\Framework\TestCase; + + final class SomeTestWithoutStrict extends TestCase + { + public function test() + { + } + } +``` + +
+ +### 4. No more call on `null` errors + +Sometimes a method can return `null`, and we hope it will not. PHPStan spots these "call on possible null" cases, so Rector can help us fix them: + +```diff + use PHPUnit\Framework\TestCase; + + final class SomeTest extends TestCase + { + public function test() + { + $someObject = $this->getSomeObject(); ++ $this->assertInstanceOf(SomeClass::class, $someObject); + + $value = $someObject->getSomeMethod(); + } + + private function getSomeObject(): ?SomeClass + { + // ... + } + } +``` + +
+ +### 5. More Readable Data Providers + +Nobody likes arrays, except legacy projects who have no choice. What's even worse are nested arrays. Array in array in array. That's how data provide methods looks like. But they don't have to! + +You can use `yield` with each case on standalone line - and include data just that particular line: + +```diff + use PHPUnit\Framework\TestCase; + + final class SomeTest implements TestCase + { + public static function provideData() + { +- $value = 'last text, but defined here'; +- +- return [ +- ['some text'], +- ['another text'], +- ['third text'], +- [$value], +- ]; + ++ yield ['some text']; ++ yield ['another text']; ++ yield ['third text']; + ++ $value = 'last text, but defined here'; ++ yield [$value]; + } + } + ``` + +
+ +If you still fancy `array` for data providers, make sure they're neatly indented: + +```diff +- return [['content', 8], ['content123', 11]]; ++ return [ ++ ['content', 8], ++ ['content123', 11] ++ ]; +``` + +
+ +From `@testWith` to data provider method: + +```diff ++public function dataProviderSum() ++{ ++ return [ ++ [0, 0, 0], ++ [0, 1, 1], ++ [1, 0, 1], ++ [1, 1, 3] ++ ]; ++} ++ + /** +- * @testWith [0, 0, 0] +- * @testWith [0, 1, 1] +- * @testWith [1, 0, 1] +- * @testWith [1, 1, 3] ++ * @dataProvider dataProviderSum + */ +-public function testSum(int $a, int $b, int $expected) ++public function test(int $a, int $b, int $expected) + { + $this->assertSame($expected, $a + $b); + } +``` + +
+ +...and much more. These are all part of *PHPUnit Code Quality set* with nearly 50 rules that work for you. + +
+ +To enable it, just add this line to your `rector.php` config: + +```php +use Rector\Config\RectorConfig; + +return RectorConfig::configure() + ->withPreparedSets(phpunitCodeQuality: true); +``` + +
+ +That's it! If some rule doesn't fit your coding style, you can always [skip it](https://getrector.com/documentation/ignoring-rules-or-paths). + +
+ +Happy coding! From f8047df28cd9f7fc9a2c04944c167e024b42ae65 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Wed, 12 Nov 2025 13:37:25 +0000 Subject: [PATCH 2/2] [rector] Rector fixes --- rector.php | 2 +- src/Controller/Stats/FindRuleStatsController.php | 2 +- src/DemoRunner.php | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/rector.php b/rector.php index 4645ec03e..3569811c9 100644 --- a/rector.php +++ b/rector.php @@ -22,7 +22,7 @@ ) ->withPhpSets() ->withAttributesSets() - ->withComposerBased(symfony: true, twig: true, laravel: true, doctrine: true, phpunit: true, netteUtils: true) + ->withComposerBased(twig: true, doctrine: true, phpunit: true, symfony: true, netteUtils: true, laravel: true) ->withSkip([ RenameForeachValueVariableToMatchMethodCallReturnTypeRector::class => [ // metadata -> datum false positive diff --git a/src/Controller/Stats/FindRuleStatsController.php b/src/Controller/Stats/FindRuleStatsController.php index 20036e104..bab39d3b4 100644 --- a/src/Controller/Stats/FindRuleStatsController.php +++ b/src/Controller/Stats/FindRuleStatsController.php @@ -78,7 +78,7 @@ private function loadFileToJsonItems(string $filePath): array continue; } - $items[] = Json::decode($fileLine, true); + $items[] = Json::decode($fileLine, forceArrays: true); } return $items; diff --git a/src/DemoRunner.php b/src/DemoRunner.php index 48356ebad..cfd50a3be 100644 --- a/src/DemoRunner.php +++ b/src/DemoRunner.php @@ -107,7 +107,7 @@ private function processFilesContents(string $fileContent, string $rectorConfig) // is valid json? try { - return Json::decode($output, true); + return Json::decode($output, forceArrays: true); } catch (JsonException $jsonException) { if ($jsonException->getMessage() === 'Syntax error') { $errorMessage = 'Invalid json syntax in "vendor/bin/rector" process output: ' . PHP_EOL . PHP_EOL . $output;