๐งช PHPUnit Testing Framework
PHPUnit is the framework the entire PHP world tests with โ Laravel, Symfony, WordPress, and virtually every serious package rely on it. In this lesson you'll go from an empty project to a green test suite, learning how to structure test cases, assert behaviour, feed in data, expect exceptions, and measure how much of your code your tests actually exercise.
๐ฏ Learning Objectives
By the end of this lesson, you will be able to:
- Install and configure PHPUnit 11 in a project with Composer and
phpunit.xml - Write a test case that extends
TestCaseusing the Arrange-Act-Assert pattern - Choose the right assertion and use fixtures (
setUp/tearDown) to avoid duplication - Run one test across many inputs with a data provider (attribute syntax)
- Assert that code throws the correct exception, and generate a code-coverage report
Estimated Time: 45โ60 minutes โข Difficulty: Intermediate
Hands-on: Write a complete, passing test suite for a ShoppingCart class.
In This Lesson
Why PHPUnit?
PHPUnit, created by Sebastian Bergmann, is the de-facto standard for automated testing in PHP. An automated test is simply code that runs your code and checks that it did the right thing โ so that a computer, not a human clicking through a browser, catches regressions the moment they appear.
๐ก A useful analogy: PHPUnit is the quality-control station on a factory line. Every product (a function's output) is measured against a spec (an assertion) before it ships. When someone later tweaks the machinery, the same checks run again and instantly flag anything that no longer meets spec.
Tests pay for themselves in four ways: they prevent regressions, they give you confidence to refactor, they act as living documentation of how a class is meant to behave, and โ because hard-to-test code is usually badly-designed code โ they quietly push you toward better architecture.
Installation & Configuration
The right way to add PHPUnit is as a development dependency through Composer, so it ships with your source but never with production:
# Install PHPUnit 11 as a dev dependency
composer require --dev phpunit/phpunit ^11.0
# Confirm the version
./vendor/bin/phpunit --version
The configuration file
PHPUnit reads a phpunit.xml in your project root. This file tells it where the tests live, how to autoload code, and how to measure coverage:
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true"
cacheDirectory=".phpunit.cache"
failOnWarning="true"
failOnDeprecation="true">
<testsuites>
<testsuite name="Unit">
<directory>tests/Unit</directory>
</testsuite>
<testsuite name="Integration">
<directory>tests/Integration</directory>
</testsuite>
</testsuites>
<source>
<include>
<directory suffix=".php">src</directory>
</include>
</source>
<php>
<env name="APP_ENV" value="testing"/>
</php>
</phpunit>
โ ๏ธ Watch the version
PHPUnit 10 renamed the coverage element from <coverage><include> to a top-level <source> block, and PHPUnit 11 continues that. Copying an old PHPUnit 9 config into a new project is the most common "why won't it configure" error. Setting failOnDeprecation="true" keeps you ahead of the next round of changes.
A conventional layout
Keep source and tests in mirror directories so a test's location tells you what it covers:
my-project/
โโโ src/
โ โโโ Calculator.php
โโโ tests/
โ โโโ Unit/
โ โ โโโ CalculatorTest.php
โ โโโ Integration/
โโโ composer.json
โโโ phpunit.xml
Add a psr-4 autoload entry in composer.json so both src/ and tests/ are discoverable, then run composer dump-autoload:
{
"autoload": { "psr-4": { "App\\": "src/" } },
"autoload-dev": { "psr-4": { "Tests\\": "tests/" } }
}
Your First Test
Let's test a tiny Calculator. First the class under test:
<?php
// src/Calculator.php
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;
}
}
Now the test. Modern PHPUnit favours PHP 8 attributes (#[Test]) over the older "method name must start with test" convention, and over docblock annotations:
<?php
// tests/Unit/CalculatorTest.php
namespace Tests\Unit;
use App\Calculator;
use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\Attributes\Test;
class CalculatorTest extends TestCase
{
private Calculator $calculator;
protected function setUp(): void
{
// Arrange: a fresh instance before every test
$this->calculator = new Calculator();
}
#[Test]
public function it_adds_two_numbers(): void
{
$result = $this->calculator->add(5, 3); // Act
$this->assertSame(8.0, $result); // Assert
}
#[Test]
public function it_divides_two_numbers(): void
{
$this->assertSame(2.0, $this->calculator->divide(6, 3));
}
}
๐ The Arrange-Act-Assert pattern
Arrange the objects and data you need, Act by calling the one method you're testing, then Assert the outcome. One logical action and one clear assertion per test keeps failures easy to diagnose.
Running the suite
# Everything
./vendor/bin/phpunit
# One file
./vendor/bin/phpunit tests/Unit/CalculatorTest.php
# One method by name
./vendor/bin/phpunit --filter it_adds_two_numbers
Output
PHPUnit 11.0.0 by Sebastian Bergmann and contributors.
.. 2 / 2 (100%)
Time: 00:00.012, Memory: 6.00 MB
OK (2 tests, 2 assertions)
Each dot is a passing test. When something fails you'll see an F and a diff of expected vs actual โ the whole point of the exercise.
That loop is Test-Driven Development (TDD): red, green, refactor. You don't have to practise TDD strictly, but the loop shows why a fast test suite is worth the effort.
Anatomy of a Test Case
A test case is a class extending PHPUnit\Framework\TestCase. PHPUnit gives you four fixture hooks that run around your tests so each one starts from a clean, known state:
use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\Attributes\Test;
class DatabaseTest extends TestCase
{
private Database $db;
// Once, before any test in this class
public static function setUpBeforeClass(): void { /* shared, expensive setup */ }
// Before every test method
protected function setUp(): void
{
$this->db = new Database();
$this->db->createTable('users');
}
// After every test method
protected function tearDown(): void
{
$this->db->dropTable('users');
}
// Once, after the last test in this class
public static function tearDownAfterClass(): void { /* release shared resources */ }
}
โ ๏ธ Isolation is the whole game
Because setUp() runs before every test, no test can depend on state left behind by another. If your tests only pass when run in a certain order, you have a hidden shared-state bug โ fix the setup, don't reorder the tests.
Naming that documents behaviour
Read your test names as sentences. testDivide tells you nothing; divide_by_zero_throws_exception tells you exactly what the class promises:
#[Test]
public function find_user_by_id_returns_null_when_not_found(): void { /* ... */ }
#[Test]
public function register_with_invalid_email_throws_exception(): void { /* ... */ }
Assertions
Assertions are the checks at the heart of every test. Pick the most specific one โ a precise assertion produces a precise failure message.
Everyday assertions
// Equality โ assertSame uses === (type + value), assertEquals uses ==
$this->assertSame(8, $result); // prefer this; 8 !== "8"
$this->assertEquals(8, $result); // looser; use only when you mean it
// Booleans and null
$this->assertTrue($user->isActive());
$this->assertNull($repository->findById(999));
// Arrays and collections
$this->assertCount(3, $items);
$this->assertContains('php', $tags);
$this->assertArrayHasKey('email', $data);
// Types
$this->assertInstanceOf(User::class, $result);
๐ก assertSame vs assertEquals
assertEquals uses loose comparison, so assertEquals(1, '1') passes. assertSame uses strict comparison and also checks the two variables are the identical object instance. Reach for assertSame by default โ it catches accidental type coercion that assertEquals hides.
String and JSON assertions
$this->assertStringContainsString('welcome', $html);
$this->assertStringStartsWith('https://', $url);
$this->assertMatchesRegularExpression('/^\d{4}-\d{2}-\d{2}$/', $date);
$this->assertJsonStringEqualsJsonString('{"ok":true}', $response->body());
Data Providers
When a method should behave correctly across many inputs, don't copy-paste the test โ feed it a data provider. A provider is a static method returning rows of arguments; PHPUnit runs the test once per row and labels each with its key.
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\DataProvider;
class CalculatorTest extends TestCase
{
private Calculator $calculator;
protected function setUp(): void
{
$this->calculator = new Calculator();
}
#[Test]
#[DataProvider('additionCases')]
public function it_adds_correctly(float $a, float $b, float $expected): void
{
$this->assertSame($expected, $this->calculator->add($a, $b));
}
public static function additionCases(): array
{
return [
'positives' => [5, 3, 8.0],
'negatives' => [-5, -3, -8.0],
'mixed sign' => [5, -3, 2.0],
'with zero' => [0, 0, 0.0],
'decimals' => [1.5, 2.5, 4.0],
];
}
}
โ ๏ธ Providers must be static
PHPUnit 10+ requires data-provider methods to be public static, and the modern #[DataProvider('name')] attribute replaced the old @dataProvider docblock. A non-static provider now raises a deprecation and will eventually error.
The payoff is real: adding a new edge case is a one-line change, and a failure names the exact case (it_adds_correctly with data set "decimals"), so you know instantly which input broke.
Testing Exceptions
Error handling is behaviour too, and it deserves tests. Tell PHPUnit what you expect before the code that should throw:
#[Test]
public function divide_by_zero_throws_exception(): void
{
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('Cannot divide by zero');
$this->calculator->divide(5, 0);
}
โ ๏ธ Put the expectation first
If you call expectException() after the throwing line, the exception fires before the expectation is set and the test errors instead of passing. Always: expect, then act. Also assert nothing after the throwing call โ code below it never runs.
When you need to inspect the exception object itself, use a try/catch with an explicit fail() so a missing exception is still a failure:
#[Test]
public function division_error_carries_the_offending_values(): void
{
try {
$this->calculator->divide(5, 0);
$this->fail('Expected InvalidArgumentException was not thrown');
} catch (\InvalidArgumentException $e) {
$this->assertStringContainsString('zero', $e->getMessage());
}
}
Code Coverage
Code coverage reports which lines your tests actually execute. It needs a coverage driver โ PCOV (fast) or Xdebug (also a debugger):
# Install a driver, then generate reports
pecl install pcov
./vendor/bin/phpunit --coverage-text # summary in the terminal
./vendor/bin/phpunit --coverage-html coverage # browsable HTML report
๐ก Coverage is a map, not a score
High coverage tells you which lines ran during tests โ not that they were meaningfully checked. You can execute a line with zero assertions about its result. Use coverage to find code no test touches at all, then write tests that actually assert behaviour. Chasing 100% for its own sake produces brittle tests that assert nothing.
Hands-on Exercise
๐๏ธ Test a Shopping Cart
Objective: Write a complete, passing test suite for the class below. Practise fixtures, assertions, a data provider, and exception testing all in one place.
Here is the class under test:
<?php
// src/ShoppingCart.php
namespace App;
class ShoppingCart
{
private array $items = [];
public function add(string $sku, float $price, int $qty = 1): void
{
if ($qty <= 0) {
throw new \InvalidArgumentException('Quantity must be positive');
}
if (isset($this->items[$sku])) {
$this->items[$sku]['qty'] += $qty;
} else {
$this->items[$sku] = ['price' => $price, 'qty' => $qty];
}
}
public function count(): int
{
return array_sum(array_column($this->items, 'qty'));
}
public function total(): float
{
$total = 0.0;
foreach ($this->items as $item) {
$total += $item['price'] * $item['qty'];
}
return $total;
}
}
Your task:
- Create
tests/Unit/ShoppingCartTest.phpwith asetUp()that makes a fresh cart. - Assert an empty cart has a
count()of 0 and atotal()of 0.0. - Assert that adding the same SKU twice merges quantities rather than duplicating the line.
- Use a data provider to check
total()across several baskets. - Assert that adding a non-positive quantity throws
InvalidArgumentException.
๐ก Hint
For the merge test, add 'A', 10.0, 2 then 'A', 10.0, 3 and assert count() is 5. For the provider, each row can be a list of [sku, price, qty] tuples plus the expected total; loop over them inside the test.
โ Sample solution
<?php
namespace Tests\Unit;
use App\ShoppingCart;
use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\DataProvider;
class ShoppingCartTest extends TestCase
{
private ShoppingCart $cart;
protected function setUp(): void
{
$this->cart = new ShoppingCart();
}
#[Test]
public function a_new_cart_is_empty(): void
{
$this->assertSame(0, $this->cart->count());
$this->assertSame(0.0, $this->cart->total());
}
#[Test]
public function adding_the_same_sku_merges_quantities(): void
{
$this->cart->add('A', 10.0, 2);
$this->cart->add('A', 10.0, 3);
$this->assertSame(5, $this->cart->count());
$this->assertSame(50.0, $this->cart->total());
}
#[Test]
#[DataProvider('baskets')]
public function it_totals_a_basket(array $lines, float $expected): void
{
foreach ($lines as [$sku, $price, $qty]) {
$this->cart->add($sku, $price, $qty);
}
$this->assertSame($expected, $this->cart->total());
}
public static function baskets(): array
{
return [
'single item' => [[['A', 9.99, 1]], 9.99],
'two products' => [[['A', 10.0, 2], ['B', 5.0, 1]], 25.0],
];
}
#[Test]
public function adding_a_non_positive_quantity_throws(): void
{
$this->expectException(\InvalidArgumentException::class);
$this->cart->add('A', 10.0, 0);
}
}
Best Practices
| โ Do | โ Avoid |
|---|---|
| One logical assertion per test | Testing five behaviours in one method |
| Descriptive names that read as sentences | test1, testStuff |
Use assertSame for exact checks | Defaulting to loose assertEquals |
Reset all state in setUp() | Tests that depend on run order |
| Data providers for input variations | Copy-pasting near-identical tests |
| Test edge cases and error paths | Only testing the happy path |
โ The FIRST principles
Good unit tests are Fast, Isolated, Repeatable, Self-validating (they pass or fail with no human interpretation), and Timely (written close to the code they cover). If a test is slow or flaky, treat that as a bug in the test.
Summary & Quiz
๐ Key Takeaways
- PHPUnit is installed with Composer as a dev dependency and configured via
phpunit.xml(note the<source>block in v10+). - A test case extends
TestCase;#[Test]and the Arrange-Act-Assert pattern keep tests clear. - Fixtures (
setUp/tearDown) guarantee each test runs in isolation. - Data providers (now
static+#[DataProvider]) run one test across many inputs. expectException()tests error paths; coverage shows which lines your tests reach.
๐ฏ Quick Quiz
Question 1: Why is assertSame(8, $result) usually preferable to assertEquals(8, $result)?
Question 2: In PHPUnit 10+, a data-provider method must be:
Question 3: What does setUp() guarantee?
๐ Further Reading
- PHPUnit 11 official documentation
- PHPUnit โ full list of assertions
- What changed in PHPUnit 10 (attributes & config)
๐ What's Next?
PHPUnit is the engine โ but WordPress adds its own testing layer on top of it. Next we'll look at how to test themes, plugins, hooks, and the REST API using the WordPress test suite.
๐ Well done!
You can now write, run, and reason about a PHP test suite. Every stack in this course tests on the same principles โ you'll recognise them everywhere.