PHP · Course navigation

PHP unit tests with closures

Write a tiny test runner with named test closures and check normal values and expected errors.

On this page
Before you start

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
<?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";
Output
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
Guided walkthroughReady
1<?php
2class AssertionFailure extends Exception {}
3
4function assertSame(mixed $expected, mixed $actual): void
5{
6 if ($expected !== $actual) {
7 throw new AssertionFailure("expected " . var_export($expected, true) . " but got " . var_export($actual, true));
8 }
9}
10
11function assertThrows(string $class, callable $code): void
12{
13 try {
14 $code();
15 } catch (Throwable $e) {
16 if ($e instanceof $class) {
17 return;
18 }
19 throw new AssertionFailure("expected $class but got " . get_class($e));
20 }
21 throw new AssertionFailure("expected $class but nothing was thrown");
22}
23
24function clamp(int $value, int $low, int $high): int
25{
26 if ($low > $high) {
27 throw new InvalidArgumentException("low > high");
28 }
29 return max($low, min($high, $value));
30}
31
32$tests = [
33 "inside the range" => fn() => assertSame(5, clamp(5, 0, 10)),
34 "below the range" => fn() => assertSame(0, clamp(-3, 0, 10)),
35 "above the range" => fn() => assertSame(10, clamp(99, 0, 10)),
36 "bad bounds throw" => fn() => assertThrows(InvalidArgumentException::class, fn() => clamp(1, 5, 2)),
37 "division by zero" => fn() => assertThrows(DivisionByZeroError::class, fn() => intdiv(1, 0)),
38 "a failing check" => fn() => assertSame(6, clamp(7, 0, 5)),
39];
40
41$failed = 0;
42foreach ($tests as $name => $test) {
43 try {
44 $test();
45 echo "PASS $name\n";
46 } catch (AssertionFailure $e) {
47 $failed++;
48 echo "FAIL $name: ", $e->getMessage(), "\n";
49 }
50}
51echo 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
Prepared trace of the published program. Some rows group repeated iterations.
Step / lineWhat happensState after stepOutput 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.

Further reading