TL;DR

Learn PHP testing with PHPUnit. Write test classes, use assertions, data providers, and mocking for reliable, maintainable code.

Key concepts

  • PHPUnit tutorial
  • PHP testing
  • PHP unit tests
  • PHPUnit assertions

Testing With PHPUnit

Untested code is code you cannot confidently change. PHPUnit is the standard testing framework for PHP — it gives you a structured way to verify that your functions and classes behave exactly as intended, and to catch regressions the moment they happen.

This lesson walks through writing real tests: from a basic assertion to data providers, exception testing, and simple mocking. By the end, you will have the vocabulary and patterns to test any PHP codebase.

Your First Test Class

A PHPUnit test is a class that extends PHPUnit\Framework\TestCase. Each test is a public method whose name begins with test. Inside each method you call assertion methods that compare expected values to actual ones.

<?php
// Simulating PHPUnit-style assertions without the framework binary
// In a real project: vendor/bin/phpunit

class Calculator {
    public function add(float $a, float $b): float {
        return $a + $b;
    }

    public function divide(float $a, float $b): float {
        if ($b === 0.0) {
            throw new InvalidArgumentException('Division by zero');
        }
        return $a / $b;
    }
}

// --- Lightweight test runner for the playground ---
function expect(mixed $actual, mixed $expected, string $label): void {
    $pass = $actual === $expected;
    echo ($pass ? '✓' : '✗') . " $label\n";
    if (!$pass) {
        echo "  Expected: " . var_export($expected, true) . "\n";
        echo "  Got:      " . var_export($actual, true) . "\n";
    }
}

$calc = new Calculator();

expect($calc->add(2, 3), 5.0, 'add: 2 + 3 = 5');
expect($calc->add(-1, 1), 0.0, 'add: -1 + 1 = 0');
expect($calc->divide(10, 2), 5.0, 'divide: 10 / 2 = 5');

try {
    $calc->divide(1, 0);
    echo "✗ divide by zero: should have thrown\n";
} catch (InvalidArgumentException $e) {
    echo "✓ divide by zero: exception caught — {$e->getMessage()}\n";
}

The pattern is always the same: arrange the objects you need, act by calling the method under test, and assert that the result matches expectations. This Arrange-Act-Assert structure keeps tests readable.

Common Assertions

PHPUnit ships with dozens of assertion methods. These are the ones you will reach for most often:

AssertionChecks
assertEquals($expected, $actual)Loose equality (==)
assertSame($expected, $actual)Strict equality (===)
assertTrue($value)Value is true
assertNull($value)Value is null
assertCount($n, $array)Array has exactly n items
assertStringContainsString($needle, $haystack)String contains substring
assertInstanceOf($class, $object)Object is an instance of class
expectException($class)Next statement throws this exception
<?php
function slugify(string $text): string {
    $text = strtolower(trim($text));
    $text = preg_replace('/[^a-z0-9]+/', '-', $text);
    return trim($text, '-');
}

// Simulated assertions
function assertSame(mixed $expected, mixed $actual, string $label): void {
    $pass = $expected === $actual;
    echo ($pass ? '✓' : '✗') . " $label\n";
    if (!$pass) {
        echo "  Expected: " . var_export($expected, true) . "\n";
        echo "  Got:      " . var_export($actual, true) . "\n";
    }
}

assertSame('hello-world',       slugify('Hello World'),         'basic slug');
assertSame('php-unit-testing',  slugify('  PHPUnit Testing  '), 'trims whitespace');
assertSame('foo-bar',           slugify('foo---bar'),           'collapses hyphens');
assertSame('hello',             slugify('---hello---'),         'strips leading/trailing hyphens');
assertSame('caf',               slugify('café'),                'strips non-ascii');

Data Providers

Calling the same assertion with many inputs quickly becomes repetitive. PHPUnit solves this with data providers — methods that return a list of argument sets, each run as a separate test case.

<?php
function isLeapYear(int $year): bool {
    return ($year % 400 === 0) || ($year % 4 === 0 && $year % 100 !== 0);
}

// Data provider: [year, expected]
$cases = [
    'divisible by 400'     => [2000, true],
    'divisible by 100'     => [1900, false],
    'divisible by 4'       => [2024, true],
    'not divisible by 4'   => [2023, false],
    'year 1'               => [1,    false],
    'year 4'               => [4,    true],
];

foreach ($cases as $label => [$year, $expected]) {
    $result = isLeapYear($year);
    $pass   = $result === $expected;
    echo ($pass ? '✓' : '✗') . " $label ($year)\n";
}

In a real PHPUnit test class, you annotate the test method with @dataProvider providerName (or use the #[DataProvider] attribute in PHPUnit 10+). The framework calls the test once per row and labels each failure with its key.

Testing Exceptions

Testing that your code throws the right exception is just as important as testing happy paths. PHPUnit provides expectException() and expectExceptionMessage() for this. In the playground below we replicate the pattern manually:

<?php
class UserRepository {
    private array $users = [
        1 => ['id' => 1, 'name' => 'Alice', 'email' => 'alice@example.com'],
        2 => ['id' => 2, 'name' => 'Bob',   'email' => 'bob@example.com'],
    ];

    public function findById(int $id): array {
        if (!isset($this->users[$id])) {
            throw new RuntimeException("User #{$id} not found");
        }
        return $this->users[$id];
    }

    public function findByEmail(string $email): array {
        foreach ($this->users as $user) {
            if ($user['email'] === $email) {
                return $user;
            }
        }
        throw new RuntimeException("No user with email '{$email}'");
    }
}

function assertThrows(callable $fn, string $exceptionClass, string $messageContains, string $label): void {
    try {
        $fn();
        echo "✗ $label — no exception thrown\n";
    } catch (Throwable $e) {
        $classOk   = $e instanceof $exceptionClass;
        $messageOk = str_contains($e->getMessage(), $messageContains);
        echo ($classOk && $messageOk ? '✓' : '✗') . " $label\n";
        if (!$classOk)   echo "  Wrong exception class: " . get_class($e) . "\n";
        if (!$messageOk) echo "  Message: {$e->getMessage()}\n";
    }
}

$repo = new UserRepository();

// Happy path
$user = $repo->findById(1);
echo ($user['name'] === 'Alice' ? '✓' : '✗') . " findById returns correct user\n";

// Exception paths
assertThrows(
    fn() => $repo->findById(99),
    RuntimeException::class,
    'User #99 not found',
    'findById throws for missing id'
);

assertThrows(
    fn() => $repo->findByEmail('nobody@example.com'),
    RuntimeException::class,
    "No user with email",
    'findByEmail throws for missing email'
);

Try It Yourself

Extend the OrderService below with a cancelOrder method, then add tests that cover both the success path and the exception case.

<?php
class OrderService {
    private array $orders = [];

    public function placeOrder(string $item, int $qty): int {
        $id = count($this->orders) + 1;
        $this->orders[$id] = ['item' => $item, 'qty' => $qty, 'status' => 'pending'];
        return $id;
    }

    public function getOrder(int $id): array {
        if (!isset($this->orders[$id])) {
            throw new RuntimeException("Order #{$id} not found");
        }
        return $this->orders[$id];
    }

    public function cancelOrder(int $id): void {
        if (!isset($this->orders[$id])) {
            throw new RuntimeException("Order #{$id} not found");
        }
        if ($this->orders[$id]['status'] === 'shipped') {
            throw new LogicException("Cannot cancel a shipped order");
        }
        $this->orders[$id]['status'] = 'cancelled';
    }
}

// --- Tests ---
function check(bool $pass, string $label): void {
    echo ($pass ? '✓' : '✗') . " $label\n";
}

$service = new OrderService();

$id = $service->placeOrder('Widget', 3);
check($id === 1, 'placeOrder returns id 1');

$order = $service->getOrder($id);
check($order['item'] === 'Widget', 'order has correct item');
check($order['status'] === 'pending', 'order starts as pending');

// Cancel the order
$service->cancelOrder($id);
check($service->getOrder($id)['status'] === 'cancelled', 'cancelOrder sets status to cancelled');

// Cancel non-existent order
try {
    $service->cancelOrder(999);
    check(false, 'cancel missing order should throw');
} catch (RuntimeException $e) {
    check(true, 'cancel missing order throws RuntimeException');
}

// TODO: Add a shipped order and assert that cancelling it throws LogicException
// Hint: you will need to add a `shipOrder(int $id): void` method first
echo "\n→ Add shipOrder() and test that cancelling a shipped order throws LogicException\n";

Key Takeaways

  • Arrange-Act-Assert keeps tests structured and readable — set up data, call the code, check the result.
  • assertSame beats assertEquals when types matter — it uses strict === comparison.
  • Data providers eliminate repetitive test methods and give each case a meaningful label.
  • Test exceptions explicitly — verify both the exception class and the message content.
  • One assertion per concept: it is fine to have multiple assertions in one test, but each test should cover a single behaviour.
  • Test method names should read as sentences: testDivideByZeroThrowsException tells you exactly what broke when CI is red.
  • PHPUnit integrates with Composer — install via composer require --dev phpunit/phpunit and run with vendor/bin/phpunit.

Pro Tip: Run PHPUnit with --testdox to get output formatted as human-readable sentences — Calculator > divide by zero throws exception. This doubles as living documentation and makes failing tests instantly understandable to anyone on the team.

Next Steps

You can now write structured PHPUnit tests with assertions, data providers, and exception testing. Your code has a safety net that catches regressions automatically.

As your applications grow, you will encounter the same structural problems again and again: how to create objects without hard-coding classes, how to notify multiple systems when something changes, how to swap algorithms at runtime. Design patterns are proven, named solutions to these recurring problems. Learning them gives you a shared vocabulary with other developers and a toolkit of architectural strategies that keep complex codebases maintainable.

Continue to Design Patterns -->