PHPUnit 8 and the deprecations with a deadline

The upgrade was scheduled for an afternoon on the grounds that a test framework cannot break much. Three hundred and forty test classes failed to load, none of them ran, and the reason is one word added to four method signatures in the base class.

The symptom

$ composer require --dev phpunit/phpunit:^8.0
$ vendor/bin/phpunit

PHP Fatal error:  Declaration of TestsOrderTest::setUp() must be
compatible with PHPUnitFrameworkTestCase::setUp(): void in
/app/tests/OrderTest.php on line 14

$ grep -rl 'function setUp()' tests/ | wc -l
340

A fatal rather than a warning, on the first file loaded, so nothing runs at all. That is the correct behaviour for an incompatible signature and it makes the upgrade an all-or-nothing commit rather than something that can be done gradually.

Why it happens

The template methods now declare void, and PHP requires an override to match the parent’s return type exactly. A method with no declared return type is not compatible with one declaring void, because the child would be permitted to return something the parent promised it would not.

This is the language being correct rather than PHPUnit being awkward, and the same rule is why the change could not be introduced as a deprecation. There is no intermediate state where both spellings work.

The fix

The mechanical pass

$ find tests -name '*.php' -exec sed -i -E 
    's/(protected|public) function (setUp|tearDown)()/1 function 2(): void/' {} +

$ find tests -name '*.php' -exec sed -i -E 
    's/public static function (setUpBeforeClass|tearDownAfterClass)()/public static function 1(): void/' {} +

$ git diff --stat | tail -1
 340 files changed, 412 insertions(+), 412 deletions(-)

The static pair is the part that gets missed, because they appear in perhaps a dozen files rather than all of them — so the first run after the fix looks clean and one suite fails. Running the two commands together avoids the second afternoon.

The other half of the mechanical work is parent::setUp(), which a surprising number of classes omit. It was harmless in 7 for a class extending TestCase directly and is not harmless once a project has its own base class doing setup — and the failures it produces look nothing like the cause.

The assertion split that is not a rename

// deprecated, and a straight rename
$this->assertRegExp('/^ORD-d+$/', $ref);
$this->assertFileNotExists($path);

// what to write
$this->assertMatchesRegularExpression('/^ORD-d+$/', $ref);
$this->assertFileDoesNotExist($path);

// and the one that is NOT a rename:
$this->assertContains('needle', $haystack);
// arrays  -> assertContains
// strings -> assertStringContainsString

assertContains accepted both arrays and strings and has been split, which means a blind search and replace is wrong in one direction or the other. The string form also became case-sensitive by default, so a test that passed on Order against order now fails — and that is a test which was asserting something looser than its author intended.

Rector handles the mechanical renames reliably and cannot decide the array-versus-string question, which is exactly the division of labour to expect. Running it and then reviewing every assertContains by hand is the fastest correct route.

The annotations with an expiry

// deprecated in 8, removed in 9
/**
 * @expectedException AppPaymentDeclined
 * @expectedExceptionMessage insufficient funds
 */
public function testDeclined() { /* ... */ }

// the replacement, and it is better rather than merely different
public function testDeclined(): void
{
    $gateway = new Gateway($this->declinedClient());

    $this->expectException(PaymentDeclined::class);
    $this->expectExceptionMessage('insufficient funds');

    $gateway->charge($this->card, Money::gbp(4900));
}

The annotation asserts the exception happened somewhere in the method, so a test whose arrangement throws the same type passes while testing nothing. Moving the expectation to immediately before the call makes it specific, and everything above it is arrangement asserted normally.

The trap in the new form is that nothing after the throwing call executes, so an assertion placed there is silently skipped rather than failing. Anything that needs to be checked after the exception belongs in a try/catch with an explicit fail().

Running both versions in the same week

The upgrade is one commit and the review is easier when the suite can be proven green on both versions before it lands.

# on the branch, against the old version
$ composer require --dev phpunit/phpunit:^7.5 --no-update
$ composer update phpunit/phpunit && vendor/bin/phpunit
OK (1284 tests, 3891 assertions)

# and the new one
$ composer require --dev phpunit/phpunit:^8.0 --no-update
$ composer update phpunit/phpunit && vendor/bin/phpunit
OK (1284 tests, 3891 assertions)

The void return types are valid in 7 as well, so the migrated suite genuinely runs on both — which is what makes this checkable rather than a leap. The assertion renames are not, so those go in a second commit after the version bump has landed.

Verifying it worked

$ vendor/bin/phpunit
OK (1284 tests, 3891 assertions)

$ vendor/bin/phpunit 2>&1 | grep -c 'deprecat'
0

# and the count that says the assertions still assert
$ git diff origin/master --stat -- tests/ | tail -1
 347 files changed, 486 insertions(+), 486 deletions(-)
# equal counts: signatures and renames only, no logic touched

Equal insertion and deletion counts is a cheap proof that the change was mechanical — an unequal count means somebody edited a test while they were in there, which is exactly what should not happen in this commit. The assertion total being unchanged is the other check, and it catches a test accidentally commented out during the sed.

What this costs

An upgrade that has to happen in one commit, touching every test file, which is unreviewable in the usual sense — nobody reads 340 files. The mitigation is that it is provably mechanical: a script produced it, the script is in the commit message, and the insertion and deletion counts match. That is a different kind of review and it is the honest one for a change of this shape.

The larger cost is that PHPUnit’s deprecation cycle is now short enough to require attention every major version, and each one arrives with a codemod that handles most of it and a residue that does not. Budgeting a day per major rather than treating each as a surprise is the difference between staying current and being three versions behind on a suite nobody wants to touch.