05 · Testing with PHPUnit¶
Manually running a script and eyeballing the output doesn't scale — it's slow, easy to forget, and tells you nothing about whether yesterday's code still works after today's change. PHPUnit is PHP's standard automated testing framework: you write small, self-checking functions once, then run the whole suite in seconds, forever, for free.
Installing PHPUnit¶
PHPUnit is installed as a dev dependency via Composer — it's only needed while developing, never in production.
{
"require-dev": {
"phpunit/phpunit": "^11.0"
},
"autoload-dev": {
"psr-4": {
"Tests\\": "tests/"
}
}
}
The code under test¶
<?php
// src/Calculator.php
declare(strict_types=1);
namespace App;
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("Cannot divide by zero");
}
return $a / $b;
}
}
Writing a test case¶
A test class extends PHPUnit\Framework\TestCase; every public method
whose name starts with test (or carries a #[Test] attribute) is run as
one independent test.
<?php
// tests/CalculatorTest.php
declare(strict_types=1);
namespace Tests;
use App\Calculator;
use PHPUnit\Framework\TestCase;
class CalculatorTest extends TestCase
{
private Calculator $calculator;
// Runs fresh before EVERY test method -- guarantees each test starts
// from a clean, identical object with no leftover state from another test.
protected function setUp(): void
{
$this->calculator = new Calculator();
}
public function testAddReturnsTheSum(): void
{
$result = $this->calculator->add(2, 3);
$this->assertSame(5.0, $result);
}
public function testDivideReturnsTheQuotient(): void
{
$result = $this->calculator->divide(10, 4);
$this->assertEqualsWithDelta(2.5, $result, 0.0001);
}
public function testDivideByZeroThrows(): void
{
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage("Cannot divide by zero");
$this->calculator->divide(10, 0);
}
}
Running vendor/bin/phpunit tests executes all three and prints a summary:
PHPUnit 11.x by Sebastian Bergmann and contributors.
... 3 / 3 (100%)
Time: 00:00.012, Memory: 8.00 MB
OK (3 tests, 4 assertions)
Common assertions¶
| Assertion | Passes when |
|---|---|
assertSame($expected, $actual) |
Values are equal AND same type (===) |
assertEquals($expected, $actual) |
Values are loosely equal (==) |
assertEqualsWithDelta($e, $a, $delta) |
Numbers are equal within a tolerance (for floats) |
assertTrue($value) / assertFalse($value) |
Value is exactly true / false |
assertNull($value) |
Value is null |
assertCount($n, $array) |
Array/Countable has exactly $n elements |
assertInstanceOf($class, $obj) |
Object is an instance of $class |
expectException($class) |
The test body throws $class (or a subtype) |
Prefer assertSame() over assertEquals() by default — assertEquals()
uses loose comparison and can pass for surprising reasons (like "5" ==
5.0), silently hiding bugs a strict test would catch.
Data providers: one test, many inputs¶
Repeating near-identical test methods for different inputs is a maintenance trap. A data provider runs the same test body once per row of data.
<?php
// tests/CalculatorTest.php (additional test)
use PHPUnit\Framework\Attributes\DataProvider;
class CalculatorTest extends TestCase
{
// ... testAddReturnsTheSum() etc. from above ...
#[DataProvider("additionCases")]
public function testAddWithVariousInputs(float $a, float $b, float $expected): void
{
$this->assertSame($expected, (new \App\Calculator())->add($a, $b));
}
public static function additionCases(): array
{
return [
"two positives" => [2, 3, 5],
"negative plus positive" => [-4, 10, 6],
"two negatives" => [-2, -3, -5],
"zero plus zero" => [0, 0, 0],
];
}
}
This runs testAddWithVariousInputs four times, once per array in
additionCases(), and reports each case's array key ("two positives", etc.)
individually in the output if one fails — far clearer than one test with
four manual assertions and no indication which pairing broke.
Testing that depends on collaborators: a simple test double¶
When a class depends on something slow or external (a database, an HTTP client), tests should not actually hit it. PHPUnit can create a mock object that stands in for a real dependency and returns canned responses.
<?php
interface PriceLookup
{
public function getPrice(string $sku): float;
}
class Checkout
{
public function __construct(private PriceLookup $prices) {}
public function total(array $skus): float
{
$sum = 0.0;
foreach ($skus as $sku) {
$sum += $this->prices->getPrice($sku);
}
return $sum;
}
}
<?php
// tests/CheckoutTest.php
class CheckoutTest extends TestCase
{
public function testTotalSumsPricesFromLookup(): void
{
// A test double that implements PriceLookup without touching a
// real database -- fast, deterministic, no network required.
$prices = $this->createMock(PriceLookup::class);
$prices->method("getPrice")->willReturnMap([
["WIDGET", 9.99],
["GADGET", 19.99],
]);
$checkout = new Checkout($prices);
// Floats: never assertSame() a computed sum against a literal --
// binary floating-point rounding can make 9.99 + 19.99 differ from
// the literal 29.98 in its very last bit. A small delta avoids that.
$this->assertEqualsWithDelta(29.98, $checkout->total(["WIDGET", "GADGET"]), 0.0001);
}
}
Depending on an interface (PriceLookup), not a concrete class, is what
makes this mockable — see OOP Deep Dive for why
coding against interfaces pays off beyond just testing.
PHPUnit cheat sheet¶
| Command / concept | Purpose |
|---|---|
vendor/bin/phpunit tests |
Run every test in the tests/ directory |
vendor/bin/phpunit --filter testName |
Run only tests matching a name |
setUp() / tearDown() |
Runs before / after each test method |
#[DataProvider("method")] |
Run one test body across many input rows |
$this->createMock(Interface::class) |
Create a fake collaborator for isolated testing |
phpunit.xml |
Config file: test suite locations, bootstrap file, coverage settings |
How It Actually Works¶
PHPUnit doesn't statically analyze your source — it uses PHP's own reflection API (ReflectionClass) at runtime to discover which methods on a TestCase subclass are actual tests, by scanning for method names beginning with test or carrying a #[Test] attribute. For every single test method, PHPUnit constructs a brand-new instance of your test class before invoking it, meaning setUp() runs fresh for each test with no state bleeding between them — this instance-per-test-method design is a deliberate mirror of PHP's own shared-nothing request model. Assertions like assertEquals() don't throw a generic error on failure; they throw a PHPUnit\Framework\ExpectationFailedException, a real Throwable, which is caught by PHPUnit's own test-runner loop (using the exact try/catch mechanics from the error-handling lessons) and recorded as a failure rather than propagating out and crashing the whole suite. Data providers work by reflection too: PHPUnit calls your provider method once, iterates the returned array/generator, and invokes the test method once per row, passing each row's elements as separate arguments — so one test method with a five-row provider genuinely becomes five independent test executions, each getting its own fresh instance per the rule above.
Exercise¶
Write a StringHelper class with a method slugify(string $text): string
that lowercases text, replaces runs of non-alphanumeric characters with a
single hyphen, and trims leading/trailing hyphens (e.g. "Hello, World!" →
"hello-world"). Write a StringHelperTest with at least four cases via a
#[DataProvider] covering: normal text, text with multiple punctuation
marks in a row, leading/trailing punctuation, and an empty string. Run the
suite with vendor/bin/phpunit and confirm all cases pass.