skip to content

With PHPUnit 13, what do #[CoversClass] and #[UsesClass] declare on a test class, and how do they change the coverage a test contributes?

level: seniorimportance: should knowfreq 30%

answer

  1. intent, not just documentation
  2. class-level, repeatable attributes
  3. only covered targets get credit
  4. used code runs without earning coverage
  5. strict mode: unlisted code makes it risky

basics

~10 s

#[CoversClass(X::class)] declares which code a test class intends to test; #[UsesClass(Y::class)] declares code it may execute without testing. With coverage on, only lines in covered targets are credited to that test.

solid answer

~40 s

Both are repeatable, class-level attributes. `#[CoversClass(PasswordStrength::class)]` says these tests are meant to exercise `PasswordStrength`; when coverage is collected, PHPUnit credits the test only with executed lines inside its covered targets, so incidental execution of other classes earns no coverage. `#[UsesClass(PasswordPolicy::class)]` lists code the test is allowed to run - collaborators, value objects - without claiming to test it. A test with no covers attributes is credited with everything it executes. With the strict coverage-metadata setting on, running code that is neither covered nor used marks the test risky ('executed code that is not listed as code to be covered or used'). Finer-grained variants exist: `#[CoversMethod]`, `#[CoversFunction]`, `#[CoversTrait]`, and since 13.3 filesystem targets such as `#[CoversFile]`. `#[CoversNothing]` opts a test out of coverage.

code

php · 22 lines
php
<?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\UsesClass;
use PHPUnit\Framework\TestCase;

#[CoversClass(PasswordStrength::class)]
#[UsesClass(PasswordPolicy::class)]
final class PasswordStrengthTest extends TestCase
{
    public function testRejectsPasswordBelowMinimumLength(): void
    {
        $policy = new PasswordPolicy(minLength: 12);

        $this->assertFalse((new PasswordStrength($policy))->isStrong('Ab1!'));
    }
}

go deeper

for a junior

Recall that #[CoversClass] names the class a test class is meant to test and #[UsesClass] names collaborators it may run.

for a middle

Explain that only covered targets earn coverage, that the attributes are class-level, and what #[CoversNothing] does.

for a senior

Show how targeting plus strict metadata keeps coverage honest in CI, and how you diagnose a test that turns risky after a refactor.

for a principal

Decide a suite policy: which tests target, which opt out with #[CoversNothing], and when strict metadata becomes a CI gate.

## Coverage targeting in one sentence **Code coverage** records which lines of your source ran during tests. **Coverage targeting** adds intent: each test class states which code it is *meant* to test, and PHPUnit uses that statement to decide which executed lines the test gets credit for. In PHPUnit 13 you express it with attributes from `PHPUnit\Framework\Attributes`. ## The two attributes - `#[CoversClass(PasswordStrength::class)]` - the tests in this class intend to test `PasswordStrength`. Lines executed inside it count as covered by these tests. - `#[UsesClass(PasswordPolicy::class)]` - these tests are allowed to execute `PasswordPolicy` (a collaborator or value object), but they do not claim to test it. Its executed lines earn **no** coverage from these tests. Both attributes are **repeatable** and target the **class only**; putting one on a test method makes PHPUnit report an invalid attribute. Apart from `#[CoversNothing]`, every `Covers*` and `Uses*` attribute is class-level. ```php #[CoversClass(PasswordStrength::class)] #[UsesClass(PasswordPolicy::class)] final class PasswordStrengthTest extends TestCase { // tests that construct a PasswordPolicy and call PasswordStrength::isStrong() } ``` ## How the coverage changes | Test class declares | Lines credited to its tests | |---|---| | no `Covers*` attribute | every first-party line it executes | | `#[CoversClass(A::class)]` | only lines inside `A` | | `#[CoversClass(A::class)]` + `#[UsesClass(B::class)]` | only lines inside `A`; `B` may run but earns nothing | | `#[CoversNothing]` | nothing; no coverage is collected for it | The effect is that a class reaches 100 % only through tests that **meant** to test it. Without targeting, an integration-style test that happens to walk through `PasswordPolicy` makes that class look tested even though nothing asserts its behaviour. ## Strictness Targeting only credits lines; it does not by itself complain about extra execution. Two runner settings - owned by the configuration file, not by these attributes - turn it into a check: 1. With **strict coverage metadata** enabled, a test that executes code listed neither as covered nor as used is marked **risky**, with the message "This test executed code that is not listed as code to be covered or used" and the offending units listed. 2. With **required coverage metadata**, a test class without any covers attribute is itself marked risky: "This test does not define a code coverage target but is expected to do so". `#[UsesClass]` exists mainly for the first case: it whitelists collaborators so the strict check only fires on genuinely unexpected code. ## Sanity warnings PHPUnit emits - The same target in both a `Covers` and a `Uses` attribute. - The same target listed twice in one kind of attribute. - `Covers*`/`Uses*` together with `#[CoversNothing]`, which cancels them. ## Finer-grained targets | Attribute | Target | |---|---| | `#[CoversMethod(A::class, 'm')]` | one method | | `#[CoversFunction('fn')]` | a plain function | | `#[CoversTrait(T::class)]` | a trait | | `#[CoversNamespace('App\\Security')]` | a namespace | | `#[CoversFile]`, `#[CoversDirectory]` | filesystem paths (added in 13.3) | Each has a `Uses*` counterpart. `#[CoversNothing]` became allowed on individual methods in 13.3. ## Groups for completeness `#[Group('security')]` is the other metadata attribute you meet on the same classes. Unlike the coverage attributes it may sit on the class or on a method, and it only labels tests for selection. PHPUnit also derives internal groups from covers and uses targets, which is what lets the runner select "tests that intend to cover `PasswordStrength`". ## Judgement Targeting makes coverage honest, at the price of maintenance: when a class is split or renamed, attributes must follow. A useful policy is `#[CoversClass]` on unit tests, `#[CoversNothing]` on broad end-to-end tests that would otherwise inflate numbers, and strict metadata in CI once the suite is clean. Two misreadings are worth naming in an interview. First, the attributes are not documentation: they change which lines the report credits. Second, they do not make the run fail by themselves - unlisted code is at most a risky test, and only when the strict setting is on. Whether a risky test then fails the build is again a configuration decision. Candidates who describe targeting as "PHPUnit fails the test if it touches other classes" have merged the attribute, the strict setting and the fail-on-risky policy into one thing.

  • In PHPUnit 13, why would you mark a broad end-to-end test class with #[CoversNothing]?
    An end-to-end test executes a large part of the codebase without asserting the behaviour of most of it. Without targeting it would credit all those lines as covered and inflate the report. `#[CoversNothing]` stops coverage collection for it, so coverage reflects the focused tests that actually verify each class.
  • With PHPUnit 13 and strict coverage metadata on, a test turns risky after a refactor. What usually happened?
    The refactor moved logic into a new class that the test now executes, but the class is listed in neither `#[CoversClass]` nor `#[UsesClass]`. The risky message lists the unexpected units; add them as covered if the test should verify them, or as used if they are only collaborators.

saying these in an interview costs you the question

  • Says #[CoversClass] is only documentation with no effect on coverage
  • Puts #[CoversClass] on individual test methods
  • Thinks #[UsesClass] lines are counted as covered
  • Believes unlisted code always fails the test, regardless of settings
  • Uses targeting to reach a coverage number instead of honest attribution