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:
| Assertion | Checks |
|---|---|
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.
assertSamebeatsassertEqualswhen 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:
testDivideByZeroThrowsExceptiontells you exactly what broke when CI is red. - PHPUnit integrates with Composer — install via
composer require --dev phpunit/phpunitand run withvendor/bin/phpunit.
Pro Tip: Run PHPUnit with
--testdoxto 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.