PHPUnit 9 and the assertions that finally went

The suite had been printing deprecation notices for a year and nobody had read them, because a green build with warnings is a green build. February’s release removed what the warnings were about, and 1,200 assertion calls stopped existing.

The symptom

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

Error: Call to undefined method TestsFeatureOrderTest::assertRegExp()

$ grep -rEc 'assertRegExp|assertNotRegExp|assertFileNotExists|assertContains' 
    tests/ | grep -v ':0' | wc -l
188

$ grep -rEo 'assert[A-Za-z]+' tests/ | sort | uniq -c | sort -rn | head -6
   4102 assertSame
   1884 assertEquals
    612 assertContains        ← the difficult one
    288 assertRegExp
    141 assertFileNotExists

One hundred and eighty-eight files, and the largest group is the one that cannot be renamed mechanically. Everything else is a search and replace.

Why it happens

PHPUnit’s deprecation cycle is one major version, which is roughly a year — long enough to be reasonable and short enough that a project skipping a major arrives at removals it never saw warned. A team that upgraded 7 to 9 directly gets both sets at once.

The assertContains case is different and is worth understanding rather than translating. It accepted an array or a string and behaved differently for each, which meant one function name covered two operations — and one of them had a subtly wrong default.

The fix

Rector, for the mechanical majority

$ composer require --dev rector/rector
$ vendor/bin/rector process tests --set phpunit90 --dry-run

188 files with changes
---------------------
1) tests/Feature/OrderTest.php
    ---------- begin diff ----------
-        $this->assertRegExp('/^ORD-d+$/', $ref);
+        $this->assertMatchesRegularExpression('/^ORD-d+$/', $ref);

$ vendor/bin/rector process tests --set phpunit90

The renames are unambiguous and Rector applies them reliably: assertRegExp, assertNotRegExp, assertFileNotExists, assertDirectoryNotExists, assertNotIsWritable and a dozen others. Running with --dry-run first and reading a sample is worth the two minutes, because a codemod that touches 188 files deserves one look.

The one it cannot decide

// arrays: unchanged name, unchanged behaviour
$this->assertContains($order, $collection);

// strings: renamed AND case-sensitive now, which it was not
$this->assertStringContainsString('Order', $html);
$this->assertStringContainsStringIgnoringCase('order', $html);

// which means this test, which passed for years:
$this->assertContains('order', $html);   // matched 'Order'
// becomes one of two different assertions, and only a human
// knows which was meant.

Reaching for the ignoring-case variant everywhere makes the build green and preserves a test that was asserting something looser than its author intended. Reading each one and deciding is a genuinely useful hour: on this suite, 41 of the 612 were case-insensitive by accident, and four of those were checking for a rendered string whose capitalisation was part of the requirement.

The array form has a second trap that has always been there: it compares with == rather than ===, so assertContains('1', [1, 2]) passes. assertContainsEquals is the explicit loose version and assertContains became strict in 9, which is a behavioural change hidden inside a rename.

The annotations that are gone

// removed in 9
/**
 * @expectedException AppPaymentDeclined
 * @expectedExceptionMessage insufficient funds
 * @expectedExceptionCode 402
 */
public function testDeclined() { }

// the replacement, which 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 asserted that the exception happened somewhere in the method, so a test whose arrangement threw the same type passed while testing nothing. Moving the expectation to immediately before the call makes it specific. The trap in the new form is that nothing after the throwing call executes, so an assertion placed there is silently skipped.

Coverage configuration moved, which is a silent failure

<!-- before 9.3 -->
<filter>
  <whitelist><directory suffix=".php">src</directory></whitelist>
</filter>

<!-- 9.3, and the old form is IGNORED rather than rejected -->
<coverage>
  <include><directory suffix=".php">src</directory></include>
  <exclude><directory>src/Legacy</directory></exclude>
</coverage>
$ vendor/bin/phpunit --migrate-configuration
Created backup:         phpunit.xml.bak
Migrated configuration: phpunit.xml

# and the check, because the failure is silent
$ vendor/bin/phpunit --coverage-text | tail -3
 Classes: 41.20% (84/204)
 Methods: 58.11% (612/1053)
 Lines:   62.44% (4102/6569)      ← not 0.00%, which is the failure

A build enforcing a minimum coverage threshold now enforces it against an empty set and passes, which is worse than an error — the gate is still there and no longer gates anything. --migrate-configuration rewrites the file and keeps a backup, which is considerably safer than editing by hand. Checking the reported line count after any PHPUnit upgrade is the two-second verification that nothing went quiet.

Verifying it worked

$ vendor/bin/phpunit
OK (1,284 tests, 3,891 assertions)

# the same assertion count as before the upgrade, which is
# the evidence that nothing was accidentally removed

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

$ git diff --stat origin/main -- tests/ | tail -1
 188 files changed, 641 insertions(+), 641 deletions(-)

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

The 41 case-sensitivity decisions went in a second commit with a message explaining each cluster, because those are the ones a reviewer should actually read.

What this costs

A suite that cannot be upgraded incrementally. The removals are fatal on the first file loaded, so there is no state where half the tests run on 9 and half on 8 — it is one commit touching every test file, which is unreviewable in the usual sense. The mitigation is that it is provably mechanical: a script produced it, the script is named in the commit message, and the counts match.

The larger cost is that this recurs every major version, and PHPUnit ships one roughly annually. 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 — and the project three versions behind is the one where the upgrade genuinely is a week.