PHPUnit 11, and a suite that had already been migrated

PHPUnit 11 shipped in February. The previous major had taken a week — a rewritten extension API, annotations to attributes, a reworked configuration file — and this one took two hours, entirely because that week had been spent properly.

The symptom

$ composer require --dev phpunit/phpunit:^11.0 --dry-run
  phpunit/phpunit  10.5.11 => 11.0.3

$ vendor/bin/phpunit 2>&1 | grep -c 'Deprecat'
11

$ vendor/bin/phpunit 2>&1 | grep 'Deprecat' | sort -u
  Metadata in doc-comments is deprecated     (4)
  Support for PHPUnitFrameworkTestCase::
    expectDeprecation() is deprecated        (7)

Two causes, eleven occurrences, and both of them had been printing a warning for a year. A major version that removes what the previous one deprecated is the cheap kind, and it is cheap in exact proportion to how much of that warning was acted on.

Why it happens

A deprecation warning is information with no deadline attached, so it accumulates. The version that removes the behaviour supplies the deadline, and by then the warnings have been in the output long enough that everybody has learned to scroll past them.

The fix

The four files with annotations

$ grep -rln '@dataProvider|@test|@covers|@group' tests/
tests/Legacy/ImportFormatTest.php
tests/Legacy/LegacyRouteTest.php
tests/Support/AssertsCurrency.php
tests/Support/DatabaseTruncation.php

$ cat rector-phpunit10.php | grep -A2 'skip'
  ->withSkip([
      __DIR__ . '/tests/Legacy',
      __DIR__ . '/tests/Support',
  ]);

The 2023 Rector run had skipped two directories — Legacy because it was going to be deleted and Support because it contains traits rather than test cases. Neither reason survived a year: the legacy tests still run, and the traits carry metadata that the test cases inherit.

expectDeprecation, and its replacement

// 10.x, deprecated
public function testTheOldHelperWarns(): void
{
    $this->expectDeprecation();
    $this->expectDeprecationMessage('use formatFor() instead');

    legacy_format($money);
}

// 11.x
#[IgnoreDeprecations]
public function testTheOldHelperWarns(): void
{
    $errors = $this->captureErrors(fn () => legacy_format($money));

    self::assertStringContainsString('use formatFor()', $errors[0]);
}

The removal is deliberate rather than incidental: asserting on a deprecation was overloading the test runner’s own deprecation handling, which is what made failOnDeprecation awkward to use. Capturing the errors with a handler is more code and separates the two concerns, which is the right outcome.

The coverage driver, which was an unrelated improvement

# 11.0 drops one coverage driver, and our CI image had it
$ vendor/bin/phpunit --coverage-text
  No code coverage driver available

# the fix, and the number that came with it
- coverage: xdebug
+ coverage: pcov

  xdebug   4m 41s
  pcov     1m 12s

The upgrade forced a change that had been worth making for two years on unrelated grounds. Xdebug stays for local stepping and the CI job uses the faster driver, which is the arrangement everybody recommends and which nobody adopts until something breaks.

What did not change

  the event system         unchanged. our extension
                           and its two subscribers
                           work untouched.
  the XML configuration    unchanged.
  attributes               unchanged.
  the CI parser            unchanged, because it
                           reads JUnit XML rather
                           than the text output —
                           the lesson from 2023.

total changes: 4 test files, 7 assertions rewritten,
one CI line.

The half-day that was not the upgrade

Two hours of the upgrade and about three more reading what had accumulated around it. The Legacy test directory has 188 tests covering routes that were replaced in 2022, and nobody has confirmed the code they exercise is still reachable — it is excluded from Rector, excluded from the analyser, and included in every run.

That is the actual finding and it has nothing to do with PHPUnit. An exclusion added to make a migration tractable is a promise to come back, and the only record of it is a path in a configuration file that the next person will read as a deliberate architectural boundary rather than as a note to self from 2023.

$ ./bin/coverage-of tests/Legacy --format=paths | head -4
  src/Http/Controllers/LegacyImportController.php
  src/Import/CsvFormatV1.php
  src/Import/CsvFormatV2.php
  src/Support/LegacyRouteResolver.php

$ ./bin/route-usage --since=90d | grep -c legacy
0

# four files, no traffic in 90 days, 188 tests.

Verifying it worked

$ vendor/bin/phpunit
PHPUnit 11.0.3
  Tests: 1,414, Assertions: 4,208

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

$ vendor/bin/phpunit --coverage-clover=c.xml && 
  ./bin/coverage-summary c.xml
  Lines: 74.11%             # 74.12% on 10.5

$ gh run list --workflow=ci --limit=3 --json conclusion
  success, success, success

Identical test count, coverage to two decimal places, and a green pipeline is the whole verification for an upgrade of this kind. Writing it up at all is arguable and worth doing precisely because it was uneventful — the record of an easy upgrade is the evidence for doing the previous one properly.

What this costs

An annual major version, which is cheap only if the previous one was done properly. The two skipped directories from 2023 cost an hour this time and would have cost a day if there had been forty of them — a skip list in a migration configuration is a promise to somebody a year later, and nobody records who made it.

The Legacy directory is also still there, still running, and still named after a plan to delete it. That is the honest finding from this upgrade: the annotations were a symptom, and the actual debt is a directory of tests for code paths nobody has confirmed are still reachable.