PHP unit tests with closures
Write a tiny test runner with named test closures and check normal values and expected errors.
On this page
PHP functions, closures, and exceptions.
- Write a tiny test runner with named test closures and check normal values and expected errors.
- Predict the sample output and explain the state changes.
- Complete the practice task and check the boundary cases.
The concept
A unit test checks one behaviour of a small piece of code. PHPUnit and Pest are the standard frameworks, but a test is simply a function that calls the code and compares the result. Storing the tests in an array of name => closure lets a loop run them all and report each result, so a failing test does not stop the others.
The runner catches every Throwable. A thrown AssertionFailure means a check failed, and any other exception is an error in the code under test. Testing expected exceptions is as important as testing results: assertThrows runs a closure and fails if the expected exception class is not thrown. PHP 8 throws DivisionByZeroError for intdiv by zero, which the last test uses.
Code example
<?php
class AssertionFailure extends Exception {}
function assertSame(mixed $expected, mixed $actual): void
{
if ($expected !== $actual) {
throw new AssertionFailure("expected " . var_export($expected, true) . " but got " . var_export($actual, true));
}
}
function assertThrows(string $class, callable $code): void
{
try {
$code();
} catch (Throwable $e) {
if ($e instanceof $class) {
return;
}
throw new AssertionFailure("expected $class but got " . get_class($e));
}
throw new AssertionFailure("expected $class but nothing was thrown");
}
function clamp(int $value, int $low, int $high): int
{
if ($low > $high) {
throw new InvalidArgumentException("low > high");
}
return max($low, min($high, $value));
}
$tests = [
"inside the range" => fn() => assertSame(5, clamp(5, 0, 10)),
"below the range" => fn() => assertSame(0, clamp(-3, 0, 10)),
"above the range" => fn() => assertSame(10, clamp(99, 0, 10)),
"bad bounds throw" => fn() => assertThrows(InvalidArgumentException::class, fn() => clamp(1, 5, 2)),
"division by zero" => fn() => assertThrows(DivisionByZeroError::class, fn() => intdiv(1, 0)),
"a failing check" => fn() => assertSame(6, clamp(7, 0, 5)),
];
$failed = 0;
foreach ($tests as $name => $test) {
try {
$test();
echo "PASS $name\n";
} catch (AssertionFailure $e) {
$failed++;
echo "FAIL $name: ", $e->getMessage(), "\n";
}
}
echo count($tests) - $failed, " passed, $failed failed\n";PASS inside the range PASS below the range PASS above the range PASS bad bounds throw PASS division by zero FAIL a failing check: expected 6 but got 5 5 passed, 1 failed
Follow the code
Open the walkthrough to inspect the example one step at a time.
Show guided walkthrough
<?phpclass AssertionFailure extends Exception {} function assertSame(mixed $expected, mixed $actual): void{ if ($expected !== $actual) { throw new AssertionFailure("expected " . var_export($expected, true) . " but got " . var_export($actual, true)); }} function assertThrows(string $class, callable $code): void{ try { $code(); } catch (Throwable $e) { if ($e instanceof $class) { return; } throw new AssertionFailure("expected $class but got " . get_class($e)); } throw new AssertionFailure("expected $class but nothing was thrown");} function clamp(int $value, int $low, int $high): int{ if ($low > $high) { throw new InvalidArgumentException("low > high"); } return max($low, min($high, $value));} $tests = [ "inside the range" => fn() => assertSame(5, clamp(5, 0, 10)), "below the range" => fn() => assertSame(0, clamp(-3, 0, 10)), "above the range" => fn() => assertSame(10, clamp(99, 0, 10)), "bad bounds throw" => fn() => assertThrows(InvalidArgumentException::class, fn() => clamp(1, 5, 2)), "division by zero" => fn() => assertThrows(DivisionByZeroError::class, fn() => intdiv(1, 0)), "a failing check" => fn() => assertSame(6, clamp(7, 0, 5)),]; $failed = 0;foreach ($tests as $name => $test) { try { $test(); echo "PASS $name\n"; } catch (AssertionFailure $e) { $failed++; echo "FAIL $name: ", $e->getMessage(), "\n"; }}echo count($tests) - $failed, " passed, $failed failed\n";Follow the example
Start to inspect the source, values, and output. These prepared steps explain the published sample; grouped loop steps describe multiple iterations.
View the complete dry-run table
| Step / line | What happens | State after step | Output so far |
|---|---|---|---|
| A check that fails with a readable message when the values differ. | — | — | |
| Runs code and requires it to throw the expected class. | — | — | |
| Each test has a name and a closure that runs one check. | — | — | |
| The error case is tested as carefully as the normal cases. | — | — | |
| This test is wrong on purpose: clamp(7, 0, 5) is 5, so the report will show a failure. | — | — | |
| A test that finishes without an exception passes. | — | PASS inside the range PASS below the range PASS above the range PASS bad bounds throw PASS division by zero | |
| The failure message names the test and shows both values. | — | PASS inside the range PASS below the range PASS above the range PASS bad bounds throw PASS division by zero FAIL a failing check: expected 6 but got 5 | |
| The summary line. | — | PASS inside the range PASS below the range PASS above the range PASS bad bounds throw PASS division by zero FAIL a failing check: expected 6 but got 5 5 passed, 1 failed |
Behind the scenes
How PHP processes this code
The PHP runtime executes the script. echo writes to the response or terminal output.
Cost and common mistakes
Each test is O(1) plus the cost of the code under test.
- A deliberately failing test appears here to show what a report looks like. A real test suite should pass.
- Tests must be independent. Do not let one test depend on the state left by another.
- A test that catches Throwable too broadly can hide real bugs. Only convert the failures you expect.
Try it yourself
What does the runner do when a test throws a TypeError?
Reveal answer and reasoning
The TypeError is not an AssertionFailure, so it is not caught by the loop and ends the script. A more robust runner would catch Throwable and report it as an error.