After upgrading to PHPUnit 13, a legacy suite still uses @dataProvider, @depends, @group and @covers docblocks; what breaks, and how do you migrate?
answer
- attributes arrived in 10, docblocks gone in 12
- ignored silently, not a parse error
- ArgumentCountError for parameterised tests
- groups and coverage targets quietly vanish
- missing use import also ignored
basics
~20 sPHPUnit 13 reads only attributes, so the docblock tags are ignored without any error. Parameterised tests fail with ArgumentCountError, while dropped groups and coverage targets fail quietly; migrate each tag to its attribute and import it.
solid answer
~40 sPHPUnit 10 introduced attributes, 11 deprecated docblock metadata and 12 removed it; PHPUnit 13 has only an attribute parser, so tags are plain comments. The loud failures: a test with parameters that relied on `@dataProvider`, `@testWith` or `@depends` is called with no arguments and dies with `ArgumentCountError`. The quiet ones: `@group` no longer puts tests in a group, so `--exclude-group slow` stops excluding them; `@covers`/`@uses` no longer target coverage, so coverage numbers shift. Migrate tag by tag - `#[DataProvider]`, `#[DataProviderExternal]`, `#[TestWith]`, `#[Depends]`, `#[Group]`, `#[CoversClass]`, `#[UsesClass]` - import every attribute with `use`, make providers `public static`, and move method-level `@covers` to class-level attributes. Then compare `--list-groups` and coverage output with the old run.
code
php · 29 lines<?php
declare(strict_types=1);
namespace App\Tests\Security;
use App\Security\PasswordPolicy;
use App\Security\PasswordStrength;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Group;
use PHPUnit\Framework\Attributes\UsesClass;
use PHPUnit\Framework\TestCase;
#[CoversClass(PasswordStrength::class)]
#[UsesClass(PasswordPolicy::class)]
#[Group('security')]
final class PasswordStrengthTest extends TestCase
{
public static function passwords(): iterable
{
yield 'too short' => ['Ab1!', false];
}
#[DataProvider('passwords')]
public function testIsStrong(string $password, bool $expected): void
{
$this->assertSame($expected, (new PasswordStrength())->isStrong($password));
}
}go deeper
Recall that PHPUnit 13 reads only attributes, and that a leftover @dataProvider makes the test fail with ArgumentCountError.
Explain the tag-to-attribute mapping, the class-level placement of coverage attributes, and why a missing use import is silently ignored.
Separate the loud failures from the silent ones - groups, coverage targets - and describe how you verify the migration against a pre-upgrade baseline.
Plan the upgrade as a tracked change: automated conversion, baselines for groups and coverage, and static analysis over tests so metadata cannot vanish again.
## Why nothing complains PHPUnit's metadata moved from docblock comments to PHP **attributes** over three major versions: PHPUnit 10 added attributes, PHPUnit 11 deprecated docblock metadata, and PHPUnit 12 removed support for it. In PHPUnit 13 the metadata parser registry builds only an attribute parser. A docblock such as `/** @dataProvider passwords */` is therefore ordinary comment text. There is **no parse error, no deprecation and no warning** for it - which is exactly what makes an upgrade dangerous. ## What fails loudly Tests that **take parameters** break visibly, because the arguments they expected are no longer supplied: - `@dataProvider` and `@testWith`: the method is called once with no arguments, and PHP throws `ArgumentCountError` ("Too few arguments…"). The test reports as an error. - `@depends`: no producer value is passed, so a consumer with a parameter errors the same way. The skip-on-failure behaviour and the producer-first ordering are gone too. These are the easy half: CI goes red and points at the methods. ## What fails quietly Tags that did not affect the method signature just stop working, and the suite stays green: - `@group slow` or `@group integration`: the tests are no longer members of the group. A CI job running `--exclude-group integration` now runs them; a job running `--group integration` silently shrinks. - `@covers` and `@uses`: coverage targeting disappears. Each test is now credited with every line it happens to execute, so coverage can jump, and a configuration that requires coverage metadata starts marking those tests risky. - `@coversNothing`: tests that were deliberately excluded from coverage now contribute to it. These are the half that survives review unless you look for them. ## The mapping | Docblock tag | PHPUnit 13 attribute | Placement | |---|---|---| | `@dataProvider m` | `#[DataProvider('m')]` | method | | `@dataProvider Other::m` | `#[DataProviderExternal(Other::class, 'm')]` | method | | `@testWith [...]` | `#[TestWith([...])]` | method | | `@depends m` | `#[Depends('m')]` | method | | `@depends clone m` | `#[DependsUsingDeepClone('m')]` | method | | `@group name` | `#[Group('name')]` | class or method | | `@covers \Foo` | `#[CoversClass(Foo::class)]` | class only | | `@covers \Foo::bar` | `#[CoversMethod(Foo::class, 'bar')]` | class only | | `@uses \Foo` | `#[UsesClass(Foo::class)]` | class only | ## Traps during the migration 1. **Missing `use` imports.** PHP resolves an attribute's short name against the file's namespace and does not load the class until something instantiates it. Written as `#[DataProvider('passwords')]` without `use PHPUnit\Framework\Attributes\DataProvider;`, the name becomes `App\Tests\DataProvider`. PHPUnit only looks at attributes in its own namespace, so it silently ignores this one - the same silent failure you were trying to fix. 2. **Coverage attributes are class-level.** Old suites often wrote `@covers` on individual methods. Every `Covers*` and `Uses*` attribute except `#[CoversNothing]` targets the class, and putting one on a method makes PHPUnit report an invalid attribute. 3. **Providers must be `public static`.** Older providers written as instance methods now make PHPUnit report an invalid provider error; convert them and remove any reliance on `setUp()` state. 4. **Provider names that start with `test`.** They are also collected as tests; PHPUnit warns. ## Doing it safely - Convert mechanically with an automated refactoring tool or a scripted pass, not by hand across hundreds of files. - Before upgrading, record the old run's `--list-groups` output, test count and coverage summary; compare after. A group that shrank or a coverage figure that jumped points straight at a missed tag. - Grep the test tree for `@dataProvider`, `@depends`, `@group`, `@covers`, `@uses` and `@testWith` after the conversion; the count should be zero. - Run static analysis over tests, so an attribute whose class does not exist is reported instead of ignored. Order the work so the loud failures cannot hide the quiet ones. Converting `@dataProvider` first makes the red tests green again, and at that moment it is tempting to call the upgrade done. The group and coverage tags are the ones that need the baseline comparison, because nothing in the test output changes when they are lost; a CI lane that suddenly takes twice as long, or a coverage report that improved overnight without new tests, is the only symptom. ## The interview answer in one line Nothing warns you: parameterised tests error, while groups and coverage targets vanish silently - so migrate every tag to an imported attribute and verify groups, counts and coverage against the pre-upgrade run.
- In a PHPUnit 13 suite, why is a forgotten use statement for an attribute as dangerous as a leftover docblock?PHP resolves the attribute's short name against the file's namespace and does not autoload it until instantiation. PHPUnit only instantiates attributes from `PHPUnit\Framework\Attributes`, so `App\Tests\Group` is skipped without any message. The metadata silently disappears, exactly like an ignored docblock; static analysis over the test tree catches the unknown class.
- After migrating @covers tags to PHPUnit 13 attributes, how do you check nothing was lost?Compare against a pre-upgrade baseline: the coverage summary per class, the list of groups and the test count. A class whose coverage jumped usually lost its targeting attributes; a group that shrank lost members. A final grep for remaining `@covers`, `@group` and `@dataProvider` tags should find none.
saying these in an interview costs you the question
- Expects PHPUnit 13 to print a deprecation for every leftover docblock tag
- Assumes only parameterised tests are affected by the removal
- Moves @covers to #[CoversClass] on the test method
- Thinks PHP fails to compile when an attribute class is not imported
- Trusts a green suite after the upgrade without checking groups and coverage