Testing with PHPUnit
Automated tests are small programs that check your code does what it should. Run them after every change and you'll know in seconds whether something broke — long before a user does. PHPUnit is PHP's standard testing framework; this lesson sets it up with Composer, writes tests, reads failures and covers the habits that make code easy to test.
Setting up
Install PHPUnit as a development dependency, and set up autoloading for your code and your tests:
composer require --dev phpunit/phpunit
{
"require-dev": {
"phpunit/phpunit": "^13.4"
},
"autoload": {
"psr-4": { "App\\": "src/" }
},
"autoload-dev": {
"psr-4": { "Tests\\": "tests/" }
}
}
The code under test
<?php namespace App; use InvalidArgumentException; final class PriceCalculator { public function __construct(private float $taxRate = 0.2) {} public function withTax(float $net): float { if ($net < 0) { throw new InvalidArgumentException('Price cannot be negative'); } return round($net * (1 + $this->taxRate), 2); } public function shipping(float $orderTotal): float { return match (true) { $orderTotal >= 50 => 0.0, $orderTotal >= 20 => 3.99, default => 6.99, }; } }
Writing tests
A test class extends PHPUnit\Framework\TestCase. Each public method whose name starts with test (or that has the #[Test] attribute) is a test. Assertions such as assertSame check results; if one fails, the test fails. A data provider runs the same test with several inputs:
<?php namespace Tests; use App\PriceCalculator; use InvalidArgumentException; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; final class PriceCalculatorTest extends TestCase { private PriceCalculator $calculator; protected function setUp(): void { $this->calculator = new PriceCalculator(taxRate: 0.2); } public function testAddsTax(): void { $this->assertSame(120.0, $this->calculator->withTax(100)); } public function testRoundsToCents(): void { $this->assertSame(12.35, $this->calculator->withTax(10.29)); } public function testRejectsNegativePrices(): void { $this->expectException(InvalidArgumentException::class); $this->calculator->withTax(-1); } #[DataProvider('shippingCases')] public function testShippingDependsOnOrderTotal(float $total, float $expected): void { $this->assertSame($expected, $this->calculator->shipping($total)); } public static function shippingCases(): array { return [ 'small order' => [10.0, 6.99], 'medium order' => [20.0, 3.99], 'just below free' => [49.99, 3.99], 'free shipping' => [50.0, 0.0], ]; } }
vendor/bin/phpunit tests
PHPUnit 13.4.1 by Sebastian Bergmann and contributors. Runtime: PHP 8.5.8 ....... 7 / 7 (100%) Time: …, Memory: … OK (7 tests, 7 assertions)
Each dot is a passing test; the four data-provider rows count separately, so seven tests ran. setUp() runs before every test, giving each one a fresh calculator, so tests can't affect each other. (The time and memory figures vary from run to run.)
When a test fails
Suppose someone changes the free-shipping rule to $orderTotal > 50. The suite pinpoints the problem:
<?php namespace App; use InvalidArgumentException; final class PriceCalculator { public function __construct(private float $taxRate = 0.2) {} public function withTax(float $net): float { if ($net < 0) { throw new InvalidArgumentException('Price cannot be negative'); } return round($net * (1 + $this->taxRate), 2); } public function shipping(float $orderTotal): float { return match (true) { $orderTotal > 50 => 0.0, // bug: was >= 50 $orderTotal >= 20 => 3.99, default => 6.99, }; } }
PHPUnit 13.4.1 by Sebastian Bergmann and contributors. Runtime: PHP 8.5.8 ......F 7 / 7 (100%) Time: …, Memory: … There was 1 failure: 1) Tests\PriceCalculatorTest::testShippingDependsOnOrderTotal@free shipping with data (50.0, 0.0) Failed asserting that 3.99 is identical to 0.0. tests/PriceCalculatorTest.php:38 FAILURES! Tests: 7, Assertions: 7, Failures: 1.
The report names the test, the data set, the expected and actual values, and the line. Naming data sets ('free shipping' => …) makes failures self-explanatory.
Common assertions
| Assertion | Passes when |
|---|---|
assertSame($expected, $actual) | Same type and value (===) — prefer this |
assertEquals($expected, $actual) | Equal after type juggling (==) |
assertTrue($x), assertFalse($x), assertNull($x) | Exactly true / false / null |
assertCount($n, $array) | The array has $n elements |
assertContains($item, $array) | The array contains the item |
assertStringContainsString($needle, $haystack) | Substring found |
assertInstanceOf(Foo::class, $obj) | Object type |
expectException(Foo::class) | The rest of the test throws that exception |
Writing testable code
Code is easy to test when it takes its inputs as parameters and returns results, instead of reading globals, the clock, the database or the network directly. Pass collaborators in through the constructor (dependency injection, lesson 34), and in tests replace them with simple fakes:
<?php interface Clock { public function now(): DateTimeImmutable; } final class FixedClock implements Clock { public function __construct(private DateTimeImmutable $time) {} public function now(): DateTimeImmutable { return $this->time; } } final class Greeter { public function __construct(private Clock $clock) {} public function greeting(): string { return (int) $this->clock->now()->format('G') < 12 ? 'Good morning' : 'Good afternoon'; } } // in a test: $greeter = new Greeter(new FixedClock(new DateTimeImmutable('2026-10-08 09:00'))); $this->assertSame('Good morning', $greeter->greeting());
PHPUnit can also generate fakes ("test doubles") with $this->createStub(Clock::class) and createMock().
Configuration and habits
vendor/bin/phpunit --generate-configurationcreates aphpunit.xmlso you can run justvendor/bin/phpunit.- Name tests after behaviour:
testRejectsNegativePricessays more thantestWithTax2. - Keep tests fast and independent; use an in-memory SQLite database (
sqlite::memory:) for code that needs a database. - Write a failing test that reproduces each bug before fixing it, so the bug can never silently return.
- Run the tests automatically on every push with a CI service such as GitHub Actions.
Pest is a popular alternative test runner built on PHPUnit, with a more compact syntax (test('adds tax', fn() => expect(...)->toBe(120.0));), especially common in Laravel projects.
Try it yourself
Write the class first, then a test class with a data provider covering strong and weak passwords.