Small Rules, Big Difference
Good tests follow a few habits:
1. Clear names — testGreetEmpty says what broke without opening the file.
2. One behavior per test — small tests point straight at the problem.
3. Data providers for many cases — one table instead of copied tests.
4. Assertions that explain themselves — assertSame shows expected and actual on failure.
5. Separate methods to see everything, assertions in a row to fail fast.
6. Run tests often — after every small change, not once at the end.
7. Tests live next to the code — same project, Test.php suffix.
Failure Messages That Help
A failing test is read in a moment of confusion — the message must carry the whole story: which call, what came back, what was expected.
assertSame prints the expected and actual values as a diff on its own; the optional message argument adds the missing piece — which call produced the value.
A message with the whole story
greet returns the wrong greeting. The failure shows the custom message naming the call, then the diff: expected 'Hello, Ana!', actual 'Hi Ana' — the bug (wrong word, missing comma and exclamation mark) is visible in the output alone, without opening the code.
Output: PHP script
<?php
use PHPUnit\Framework\TestCase;
// greet has a bug: wrong word, missing comma and "!".
function greet(string $name): string {
return "Hi " . $name;
}
final class GreetTest extends TestCase {
public function testGreet(): void {
$this->assertSame("Hello, Ana!", greet("Ana"), 'greet("Ana") returned the wrong greeting');
}
}
One Behavior per Test
A test that checks five things has five reasons to fail — and its name can only describe one of them. Splitting by behavior keeps every test small and its name honest.
Here the normal greeting and the empty-name fallback are two behaviors, so they get two test methods. When one fails, its name already tells you which behavior broke.
Two behaviors, two tests
testGreetName checks the normal case, testGreetEmpty the fallback. The --testdox output lists them separately — a failure would point at exactly one behavior.
Output: PHP script
<?php
use PHPUnit\Framework\TestCase;
function greet(string $name): string {
if ($name === "") {
return "Hello, stranger!";
}
return "Hello, " . $name . "!";
}
final class GreetTest extends TestCase {
public function testGreetName(): void {
$this->assertSame("Hello, Ana!", greet("Ana"));
}
public function testGreetEmpty(): void {
$this->assertSame("Hello, stranger!", greet(""));
}
}
How Much Is Tested: Coverage
PHPUnit can measure which lines of your code the tests actually execute. It needs a coverage extension in PHP — PCOV or Xdebug — and reports the one it found in its header line.
./vendor/bin/phpunit --coverage-filter . --coverage-text DescribeTest.php measures the files in the current folder and prints the report after the test result.
The number is a compass, not a goal: a low value shows untested paths — here the test only walks the mild branch, so the freezing and hot lines are never executed. 100% coverage does not prove the code is correct, but the uncovered lines are guaranteed unchecked.
Measure the coverage
describeTemperature has three branches, the test exercises one. The report counts every executable line in the file — the test method's own line included — and says Lines: 66.66% (4/6): the two uncovered lines are the freezing and hot returns. Adding tests for the cold and hot branches would drive the number up.
Output: PHP script
<?php
use PHPUnit\Framework\TestCase;
function describeTemperature(int $celsius): string {
if ($celsius < 0) {
return "freezing";
}
if ($celsius > 30) {
return "hot";
}
return "mild";
}
final class DescribeTest extends TestCase {
public function testDescribeMild(): void {
$this->assertSame("mild", describeTemperature(20));
}
}