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
JSON composer.json
{
    "require-dev": {
        "phpunit/phpunit": "^13.4"
    },
    "autoload": {
        "psr-4": { "App\\": "src/" }
    },
    "autoload-dev": {
        "psr-4": { "Tests\\": "tests/" }
    }
}

The code under test

PHP src/PriceCalculator.php
<?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 tests/PriceCalculatorTest.php
<?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
Output
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 src/PriceCalculator.php
<?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,
        };
    }
}
Output
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

AssertionPasses 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
<?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-configuration creates a phpunit.xml so you can run just vendor/bin/phpunit.
  • Name tests after behaviour: testRejectsNegativePrices says more than testWithTax2.
  • 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.

Try it yourself · src/Password.php
Edit the code, copy it into src/Password.php, then run it with php src/Password.php (PHP 8.5). How to install PHP