Tests That Stay Useful
Name each test after the behaviour, give it one reason to fail, and read coverage as a list of untested lines rather than a score.
vendor/bin/phpunit --testdox --coverage-textA suite earns its keep only when a failure tells you what broke. A suite that fails for unclear reasons gets ignored, then deleted. These are the habits that prevent that.
Name the behaviour, not the method
test_total says nothing when it fails. test_it_adds_tax_to_the_subtotal names the rule that is now broken, so the failure reads as a sentence. Run vendor/bin/phpunit --testdox and PHPUnit prints those names as a list of what your code claims to do, which is the closest thing to documentation that cannot go stale.
One reason to fail
A test with eight assertions about five unrelated rules stops at the first failure and hides the rest. Split it. Several assertions are fine when they describe one fact, such as three fields of a single returned object, and wrong when they describe different ones.
What coverage does and does not tell you
Code coverage is the percentage of your lines that ran while the tests ran. It needs the Xdebug or PCOV extension, and --coverage-text prints it. It can tell you which lines were never executed, which is genuinely useful. It cannot tell you whether anything about them was checked.
The runnable example shows the gap. Two cases execute every line of a function, giving full line coverage, and still miss the boundary the function exists to enforce. Chasing the number produces tests written to touch lines. Write the cases the rule describes, including zero, one, many, the boundary and the invalid input, and let the number follow.
Example
When to use it
- Rename a vague test after it fails and you cannot tell from the name what rule broke.
- Split a long test so one build reports every broken validation rule instead of the first.
- Use a coverage report to find an entire class that no test has ever executed.
- Turn on failOnRisky so an assertion-free test cannot sit in the suite looking green.
More examples
The same assertion, named two ways
Identical assertions, different value on the morning something breaks. --testdox turns the method name into a sentence by splitting on underscores and camel case, which is why a name written as a statement about behaviour reads correctly there. Rename a test the moment you find yourself opening it to remember what it covers.
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
final class InvoiceTest extends TestCase
{
// Named after the method. A failure says "total failed" and nothing more.
public function test_total(): void
{
self::assertSame(1200, (new Invoice(1000))->total());
}
// Named after the rule. A failure says which rule stopped holding.
public function test_it_adds_twenty_percent_vat_to_the_subtotal(): void
{
self::assertSame(1200, (new Invoice(1000))->total());
}
}
// vendor/bin/phpunit --testdox then prints:
//
// Invoice
// ✔ Total
// ✔ It adds twenty percent vat to the subtotalSplitting a test with several reasons to fail
The split version costs more lines and pays for them on a failing build: you learn that both validation rules broke, not just the first one PHPUnit reached. It also lets each test supply input that is valid in every respect but the one under test, which is what makes the failure attributable.
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
final class RegistrationTest extends TestCase
{
// Before: four rules in one method. The first failure hides the rest.
public function test_registration(): void
{
$result = (new Registration())->handle(['email' => 'bad', 'password' => 'x']);
self::assertFalse($result->ok);
self::assertSame('Enter a valid email address.', $result->errors['email']);
self::assertSame('Use at least 12 characters.', $result->errors['password']);
self::assertCount(0, (new UserRepository($pdo))->all());
}
// After: one rule each. A run tells you about all of them at once.
public function test_it_rejects_a_malformed_email(): void
{
$result = (new Registration())->handle(['email' => 'bad', 'password' => 'correct horse battery']);
self::assertSame('Enter a valid email address.', $result->errors['email']);
}
public function test_it_rejects_a_password_under_twelve_characters(): void
{
$result = (new Registration())->handle(['email' => '[email protected]', 'password' => 'x']);
self::assertSame('Use at least 12 characters.', $result->errors['password']);
}
}Reading a coverage report
Read it as a to-do list, not a grade. The line that matters here is RefundPolicy at zero: a whole class of money-handling rules that no test has ever executed. The 84% total is noise by comparison. Coverage needs Xdebug or PCOV loaded; without a coverage driver PHPUnit stops with an error rather than reporting zero.
vendor/bin/phpunit --coverage-text --coverage-filter src
Code Coverage Report:
2026-10-04 09:14:02
Summary:
Classes: 62.50% (5/8)
Methods: 78.26% (18/23)
Lines: 84.11% (127/151)
App\Billing\Invoice
Methods: 85.71% ( 6/ 7) Lines: 92.00% ( 23/ 25)
App\Billing\RefundPolicy
Methods: 0.00% ( 0/ 4) Lines: 0.00% ( 0/ 31)
Discussion