PHP unit tests with closures
Write a tiny test runner with named test closures and check normal values and expected errors.
<?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
Explanation
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.
Explore the dry run
<?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 |
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.