Unit Testing PHP with PHPUnit

AI-assistedDrafted with AI assistance and reviewed before publishing; examples are version-pinned. Verify against official docs when in doubt.

PHP 8.4 · PHPUnit 11

Most PHP projects reach a point where every change is verified by clicking through a few pages. A test suite replaces that with one command. This chapter starts from Composer, then builds up assertions, data providers, test doubles and database tests into something that survives a refactor.

Setup

Install PHPUnit as a dev dependency. Never globally: projects pin different major versions, and the binary must match the version in composer.lock.

composer require --dev phpunit/phpunit ^11.5
vendor/bin/phpunit --version

PHPUnit 11 requires PHP 8.2 or newer and runs fine on PHP 8.4. Wire up autoloading and a script:

{
  "autoload": {
    "psr-4": { "App\\": "src/" }
  },
  "autoload-dev": {
    "psr-4": { "App\\Tests\\": "tests/" }
  },
  "scripts": {
    "test": "phpunit"
  }
}

autoload-dev keeps test classes out of the production autoloader. Run composer dump-autoload after editing it.

src/            # production code, namespace App\
tests/          # tests, namespace App\Tests\
phpunit.xml

phpunit.xml

<?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"
         failOnRisky="true">
  <testsuites>
    <testsuite name="unit">
      <directory>tests</directory>
    </testsuite>
  </testsuites>
  <source>
    <include>
      <directory>src</directory>
    </include>
  </source>
</phpunit>

What matters:

  • bootstrap points at Composer’s autoloader, otherwise use App\... fails.
  • cacheDirectory holds the result cache; add it to .gitignore.
  • <source> tells the coverage engine what “your code” means, so tests/ and vendor/ stay out of the percentages.
  • failOnWarning and failOnRisky turn “a test with no assertions” into a failure instead of a green tick.
vendor/bin/phpunit            # everything
vendor/bin/phpunit --testdox  # readable list built from method names
vendor/bin/phpunit --filter testFormatsCentsAsCurrency

The first test

<?php

declare(strict_types=1);

namespace App;

final class Price
{
    public static function format(int $cents, string $currency = 'USD'): string
    {
        if ($cents < 0) {
            throw new \InvalidArgumentException('cents must be >= 0');
        }

        return number_format($cents / 100, 2, '.', ',') . ' ' . $currency;
    }
}
<?php

declare(strict_types=1);

namespace App\Tests;

use App\Price;
use PHPUnit\Framework\TestCase;

final class PriceTest extends TestCase
{
    public function testFormatsCentsAsCurrency(): void
    {
        self::assertSame('19.99 USD', Price::format(1999));
    }

    public function testRejectsNegativeAmount(): void
    {
        $this->expectException(\InvalidArgumentException::class);
        $this->expectExceptionMessage('cents must be >= 0');

        Price::format(-1);
    }
}

expectException must be declared before the call that throws. Once the exception is thrown, the rest of the method never runs.

Choosing an assertion

Assertion Comparison Use for
assertSame ===, type and value strings, ints, the same object
assertEquals loose equality values only, use sparingly
assertTrue / assertNull boolean and null state checks
assertCount array or Countable size collection results
assertArrayHasKey key existence config and response shapes
assertStringContainsString substring log lines, exception messages
assertMatchesRegularExpression regex format validation
assertInstanceOf type factories, returned exceptions

assertSame should be the default. assertEquals treats "1" and 1 as equal, which is exactly how type bugs slip through a suite.

Data providers

use PHPUnit\Framework\Attributes\DataProvider;

#[DataProvider('amountProvider')]
public function testFormatsVariousAmounts(int $cents, string $expected): void
{
    self::assertSame($expected, Price::format($cents));
}

public static function amountProvider(): iterable
{
    yield 'zero' => [0, '0.00 USD'];
    yield 'one unit' => [100, '1.00 USD'];
    yield 'thousands' => [100000, '1,000.00 USD'];
}

The provider must be public static and return an array or iterable. String keys with yield name the failing row. Use the attribute form: the @dataProvider annotation still works in PHPUnit 11 but is on its way out, and new code should not add more of it.

Test doubles

Depend on interfaces, not concrete classes. Given:

<?php

declare(strict_types=1);

namespace App;

interface Mailer
{
    public function send(string $to, string $subject): void;
}
final class SignupService
{
    public function __construct(private Mailer $mailer) {}

    public function register(string $email): void
    {
        if (filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
            throw new \InvalidArgumentException('invalid email');
        }

        $this->mailer->send($email, 'Welcome');
    }
}

Use createMock when the interaction matters:

public function testSendsWelcomeMail(): void
{
    $mailer = $this->createMock(Mailer::class);
    $mailer->expects(self::once())
        ->method('send')
        ->with('ada@example.com', 'Welcome');

    (new SignupService($mailer))->register('ada@example.com');
}

Use createStub when you only need a return value. It makes no call-count assertions, which keeps the intent obvious:

$mailer = $this->createStub(Mailer::class);
$mailer->method('send')->willThrowException(new \RuntimeException('smtp down'));

Other expectations worth knowing:

$mock->expects(self::never())->method('send');
$mock->expects(self::exactly(2))->method('send');
$mock->expects(self::atLeastOnce())->method('send');

createMock satisfies return types automatically (null, 0, empty arrays), so no hand-written null object is needed. Do not mock the class under test — that only verifies the double.

Testing database code

Do not stand up MySQL for a unit test. PDO supports an in-memory SQLite database, and setUp gives every test a clean schema:

final class UserRepositoryTest extends TestCase
{
    private \PDO $pdo;

    protected function setUp(): void
    {
        $this->pdo = new \PDO('sqlite::memory:');
        $this->pdo->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION);
        $this->pdo->exec(
            'CREATE TABLE users (id INTEGER PRIMARY KEY, email TEXT NOT NULL UNIQUE)'
        );
    }

    public function testInsertsUser(): void
    {
        $repo = new UserRepository($this->pdo);
        $repo->create('ada@example.com');

        $count = (int) $this->pdo->query('SELECT COUNT(*) FROM users')->fetchColumn();

        self::assertSame(1, $count);
    }
}

setUp runs before every test. setUpBeforeClass runs once per class, so rows created there leak into later tests.

SQLite is not MySQL: types, AUTO_INCREMENT and several functions differ. Keep the in-memory database for logic, and run one integration pass against the real engine for dialect-specific SQL.

Coverage

Coverage needs Xdebug or pcov. Xdebug is everywhere but slow, so enable coverage only for that command:

# Linux and macOS
XDEBUG_MODE=coverage vendor/bin/phpunit --coverage-text
# Windows PowerShell
$env:XDEBUG_MODE="coverage"; vendor/bin/phpunit --coverage-text

An HTML report makes uncovered branches easier to find:

vendor/bin/phpunit --coverage-html build/coverage

Coverage measures execution, not verification. A test that calls a method and asserts nothing still paints the line green. Write the boundary and failure cases first, then look at the number.

Running in CI

- run: composer install --no-interaction --prefer-dist
- run: vendor/bin/phpunit --testdox

Flags that earn their place:

  • --fail-on-warning and --fail-on-risky: no silent passes for assertion-free tests.
  • --order-by=random: exposes hidden ordering dependencies; --random-order-seed makes a failure reproducible.
  • --stop-on-failure: shorter logs.
  • --coverage-text plus --coverage-clover when an external service consumes the report.

Locally, composer test; before pushing, the full suite alongside static analysis.

Common mistakes

1. Shared state between tests

Static properties, singletons and open connections make results depend on order. Build state per test in setUp.

2. Using assertEquals for everything

"0", 0 and false blur together under loose comparison. Prefer assertSame.

3. Declaring expectException after the call

The exception is already thrown and the test fails before the expectation is registered.

4. A non-static data provider

PHPUnit 10 and later require public static; otherwise you get an error or a deprecation notice.

5. Mocking a concrete class instead of an interface

Constructors are skipped, but signatures and final modifiers still bite. Depend on interfaces.

6. Sleeping in tests

Real waits make a suite slower every month. Inject a clock, or freeze time with Carbon::setTestNow().

7. Asserting on implementation details

Checking that a private helper ran makes every refactor red. Assert on observable behaviour.

8. Testing getters and constructors

Those tests protect nothing. Spend the effort on branches, boundaries and failure paths.

Summary

  • PHPUnit belongs in require-dev, with autoload-dev for the test namespace and bootstrap pointing at Composer.
  • assertSame is the default; data providers must be public static and use the attribute form.
  • createMock verifies interactions, createStub only returns values; depend on interfaces.
  • Test PDO code against SQLite :memory: with a fresh schema in setUp.
  • Coverage is a trend. In CI, add --fail-on-risky and random test order.

Next: put PHPUnit around the PDO repository from the practice chapters and test the API boundary.