Still on PHPUnit 4? Come to PHPUnit 8 Together in a Day

This post was updated at November 2020 with fresh know-how.
What is new?

Switch from deprecated --set option to rector.php config.


Last month I was on PHPSW meetup in Bristol UK with Rector talk. To be honest, Nette to Symfony migration under 80 hours was not a big deal there.

To my surprise, upgrading PHPUnit tests was. So I was thinking, let's take it from the floor in one go, from PHPUnit 4 to the latest PHPUnit 8.

1. Planning

Before we dive into the upgrading of our tests, we need to look at minimal PHP version required by each PHPUnit. The PHPUnit release process states, that each new PHPUnit major version requires a newer minor PHP version.

What does that mean?

PHPUnit Required PHP version Release Year
PHPUnit 4 PHP 5.3-5.6 2015
PHPUnit 5 PHP 5.6-7.4 2016
PHPUnit 6 PHP 7.0-7.4 2017
PHPUnit 7 PHP 7.1-7.4 2018
PHPUnit 8 PHP 7.2-7.4 2019

We need to plan and combine the PHP upgrade.

This is the full path we'll go through:

Note: To keep this post simple, we'll focus on PHPUnit upgrade only here. But it's possible you'll need to use Rector for PHP upgrades between PHPUnit upgrade steps too.

2. Single Version Upgrade = 1 pull-request

Do you enjoy the rush of changing a thousand files at once? It drives to do even more and more changes.

The same thing happens with upgrading code - we upgrade PHP and tests passes, great! Let's do upgrade PHPUnit. Oh, maybe we could refactor this method so it's more readable. Oh, now this other method looks silly, let's clean that up too...

STOP!

This is the easiest way to get stuck in broken tests, with dug up pieces of code all over the project, overheat our brain in huge cognitive complexity and give up rage quit the upgrade saying "it's hard as they say".

The Golden Rule of Successful Upgrade

Unless you're upgrading 10 test files, of course. But in the rest of the case, this approach would save me many failed attempts in the paths. That's why planning is the most important step of all for huge upgrade operations.

3. PHPUnit 4 to 5

From getMock() to getMockBuilder()

 final class MyTest extends PHPUnit_Framework_TestCase
 {
     public function test()
     {
-        $someClassMock = $this->getMock('SomeClass');
+        $someClassMock = $this->getMockBuilder('SomeClass')->getMock();
     }
 }

These changes can be delegated to Rector:

composer require phpunit/phpunit "^5.0" --dev

Update set in rector.php

use Rector\PHPUnit\Set\PHPUnitSetList;
use Rector\Config\RectorConfig;

return function (RectorConfig $rectorConfig): void {
    $rectorConfig->import(PHPUnitSetList::PHPUNIT_50);
};

Run Rector

vendor/bin/rector process tests

4. PHPUnit 5 to 6

This is the release where PHPUnit got namespaces, yay! Well, yay for the project, nay for the upgrades.

From Underscore to Slash

Although there is a lot of underscore <=> slash aliases for a smoother upgrade, it will be removed in the future PHPUnit version, so we better deal with it right now.

First, we need to replace all:

-PHPUnit_Framework_TestCase
+\PHPUnit\Framework\TestCase # mind the pre-slash "\"

Without pre-slash, your code might fail.

See 3v4l.org to understand why

Second, replace the rest of the PHPUnit_* classes with a namespace. Listeners, test suites, exceptions... etc. This is hell for us human, luckily easy-pick for Rector.

Add doesNotPerformAssertions to test With no Assertion

+    /**
+     * @doesNotPerformAssertions
+     */
     public function testNoError()
     {
         new SomeObject(25);
     }

setExpectedException() to expectException()

And not only that! Also, split arguments to own method:

 <?php

 class MyTest extends \PHPUnit\Framework\TestCase
 {
     public function test()
     {
-        $this->setExpectedException('SomeException', $message, 101);
+        $this->expectException('SomeException');
+        $this->expectExceptionMessage($message);
+        $this->expectExceptionCode(101);
     }
 }

Also setExpectedExceptionRegExp() was removed.

@expectedException to expectException()

These changes are a real challenge for simple human attention:

-expectedException
+expectException

Mind the missing "ed".

 <?php

 class MyTest extends \PHPUnit\Framework\TestCase
 {
-    /**
-     * @expectedException SomeException
-     */
     public function test()
     {
+        $this->expectException('SomeException');
     }
 }

These changes can be delegated to Rector:

composer require phpunit/phpunit "^6.0" --dev

Update set in rector.php

use Rector\PHPUnit\Set\PHPUnitSetList;
use Rector\Config\RectorConfig;

return function (RectorConfig $rectorConfig): void {
    $rectorConfig->import(PHPUnitSetList::PHPUNIT_60);
};

Run Rector

vendor/bin/rector process tests

5. PHPUnit 6 to 7

Remove "test" prefix on Data Providers

I have no idea how tests and data provider methods were detected before this:

 class WithTestAnnotation extends \PHPUnit\Framework\TestCase
 {
     /**
-     * @dataProvider testProvideDataForWithATestAnnotation()
+     * @dataProvider provideDataForWithATestAnnotation()
      */
     public function test()
     {
         // ...
     }

-    public function testProvideDataForWithATestAnnotation()
+    public function provideDataForWithATestAnnotation()
     {
         return ['123'];
     }
 }

Rename @scenario annotation to @test

 class WithTestAnnotation extends \PHPUnit\Framework\TestCase
 {
     /**
-     * @scenario
+     * @test
      */
     public function test()
     {
         // ...
     }
 }

Change withConsecutive() Arguments to Iterable

This rather small change can cause a huge headache. It's a fix of silent false positive.

How would you fix the following code, if you know that the argument of withConsecutive() must be iterable (array, iterator...)?

class SomeClass
{
    public function run($one, $two)
    {
    }
}

class SomeTestCase extends \PHPUnit\Framework\TestCase
{
    public function test()
    {
        $someClassMock = $this->createMock(SomeClass::class);
        $someClassMock
            ->expects($this->exactly(2))
            ->method('run')
            ->withConsecutive(1, 2, 3, 5);
    }
}

Like this?

-->withConsecutive(1, 2, 3, 5);
+->withConsecutive([1, 2, 3, 5]);

Well, the tests would pass it, but it would be another silent positive. Look at SomeClass::run() method. How many arguments does it have?

Two. So we need to create array chunks of size 2.

-->withConsecutive(1, 2, 3, 5);
+->withConsecutive([1, 2], [3, 5]);

These changes can be delegated to Rector:

composer require phpunit/phpunit "^7.0" --dev

Update set in rector.php

use Rector\PHPUnit\Set\PHPUnitSetList;
use Rector\Config\RectorConfig;

return function (RectorConfig $rectorConfig): void {
    $rectorConfig->import(PHPUnitSetList::PHPUNIT_70);
    $rectorConfig->import(PHPUnitSetList::PHPUNIT_75);
};

Run Rector

vendor/bin/rector process tests

6. PHPUnit 7 to 8

Ignore Cache File

The optional caching of test results added in PHPUnit 7.3 is not enabled by default. We need to add the cache file to .gitignore:

# .gitignore
.phpunit.result.cache

Remove type $dataName in PHPUnit\Framework\TestCase Constructor Override

 <?php

 abstract class MyAbstractTestCase extends \PHPUnit\Framework\TestCase
 {
-    public function __construct(?string $name = null, array $data = [], string $dataName = '')
+    public function __construct(?string $name = null, array $data = [], $dataName = '')
     {
     }
 }

Replace assertContains() with Specific assertStringContainsString() Method

 <?php

 final class SomeTest extends \PHPUnit\Framework\TestCase
 {
     public function test()
     {
-        $this->assertContains('foo', 'foo bar');
+        $this->assertStringContainsString('foo', 'foo bar');

-        $this->assertNotContains('foo', 'foo bar');
+        $this->assertStringNotContainsString('foo', 'foo bar');
     }
 }

Replace assertInternalType() with Specific Methods

This change is huge.

2 methods were removed, 22 methods are added.

 <?php

 final class SomeTest extends \PHPUnit\Framework\TestCase
 {
     public function test()
     {
-        $this->assertInternalType('string', $value);
+        $this->assertIsString($value);
     }
 }

Change assertEquals() method Parameters to new Specific Methods

 final class SomeTest extends \PHPUnit\Framework\TestCase
 {
     public function test()
     {
         $value = 'value';
-        $this->assertEquals('string', $value, 'message', 5.0);
+        $this->assertEqualsWithDelta('string', $value, 5.0, 'message');

-        $this->assertEquals('string', $value, 'message', 0.0, 20);
+        $this->assertEquals('string', $value, 'message', 0.0);

-        $this->assertEquals('string', $value, 'message', 0.0, 10, true);
+        $this->assertEqualsCanonicalizing('string', $value, 'message');

-        $this->assertEquals('string', $value, 'message', 0.0, 10, false, true);
+        $this->assertEqualsIgnoringCase('string', $value, 'message');
     }
 }

Last _ is now Namespaced

This removes the last piece of back-compatible underscore class:

-PHPUnit_Framework_MockObject_MockObject
+PHPUnit\Framework\MockObject\MockObject

Replace assertArraySubset() with

This method was removed because of its vague behavior. The proposed solution is rdohms/phpunit-arraysubset-asserts polyfill.

 namespace Acme\Tests;

+use DMS\PHPUnitExtensions\ArraySubset\Assert;

 final class AssertTest extends \PHPUnit\Framework\TestCase
 {
     public function testPreviouslyStaticCall(): void
     {
-        $this->assertArraySubset(['bar' => 0], ['bar' => '0'], true);
+        Assert::assertArraySubset(['bar' => 0], ['bar' => '0'], true);
     }
 }

To use this package and upgrade to it, run:

composer require --dev dms/phpunit-arraysubset-asserts

Update set in rector.php

use Rector\PHPUnit\Set\PHPUnitSetList;
use Rector\Config\RectorConfig;

return function (RectorConfig $rectorConfig): void {
    $rectorConfig->import(PHPUnitSetList::PHPUNIT80_DMS);
};

Run Rector

vendor/bin/rector process tests

Add void to PHPUnit\Framework\TestCase Methods

This one hits 2 common methods we often use:

-setUp()
+setUp(): void
-tearDown()
+tearDown(): void

Also less common ones:

For this one, we'll use little help from Symplify:

composer require symplify/phpunit-upgrader --dev
vendor/bin/phpunit-upgrader voids /tests

That's it!


Then back to Rector - Update set in rector.php

use Rector\PHPUnit\Set\PHPUnitSetList;
use Rector\Config\RectorConfig;

return function (RectorConfig $rectorConfig): void {
    $rectorConfig->import(PHPUnitSetList::PHPUNIT_80);
};

Then:

composer require phpunit/phpunit "^8.0" --dev

In the end, you should see at least PHPUnit 8.5+:

vendor/bin/phpunit --version
$ PHPUnit 8.5.15 by Sebastian Bergmann and contributors.

That's it! Congrats!


Did you find a change that we missed here? Share it in comments, so we can make this upgrade path complete and smooth for all future readers. Thank you for the whole PHP community!


Happy coding!




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