Testing with PHPUnit and Pest

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against PHP 8.5, PHPUnit 13 (13.4) and Pest 5 (5.3), checked on 2026-10-05; the test runs shown were produced with PHPUnit 13.3 and Pest 5.3. PHPUnit is PHP’s standard testing framework; Pest is a testing framework built on top of PHPUnit with a terser, function-based syntax, the default in new Laravel applications. Both run the same tests in the same engine; teams pick one style. Framework-specific testing (HTTP tests, database factories, fakes) is covered in Laravel Testing.

Installing

composer require --dev phpunit/phpunit          # PHPUnit 13 requires PHP 8.4+
composer require --dev pestphp/pest             # Pest 5 requires PHP 8.4+ (it installs PHPUnit too)
vendor/bin/pest --init                          # creates phpunit.xml, tests/Pest.php and tests/TestCase.php

PHPUnit follows a yearly major release cycle (in February), with each major version requiring a recent PHP; the Laravel 13 skeleton currently pins PHPUnit 12.5. Pest’s --init is provided through a Composer plugin: allow it in composer.json ("config": {"allow-plugins": {"pestphp/pest-plugin": true}}).

The code under test

The examples test two small classes from the library scenario: a fine calculator, and a reminder service that depends on a Mailer interface.

<?php

declare(strict_types=1);

namespace Library;

use InvalidArgumentException;

final class FineCalculator
{
    public function __construct(private int $centsPerDay = 25, private int $capCents = 1000) {}

    public function fineFor(int $daysLate): int
    {
        if ($daysLate < 0) {
            throw new InvalidArgumentException('Days late cannot be negative');
        }
        return min($this->capCents, $daysLate * $this->centsPerDay);
    }
}

interface Mailer
{
    public function send(string $to, string $subject): bool;
}

final class ReminderService
{
    public function __construct(private Mailer $mailer) {}

    /** @param list<array{email: string, title: string}> $overdue */
    public function remind(array $overdue): int
    {
        $sent = 0;
        foreach ($overdue as $loan) {
            if ($this->mailer->send($loan['email'], "Please return {$loan['title']}")) {
                $sent++;
            }
        }
        return $sent;
    }
}

In a real project each class and interface lives in its own file under src/, autoloaded with PSR-4.

PHPUnit

Test classes and attributes

A test class extends PHPUnit\Framework\TestCase; each test is a public method marked with #[Test] (or named test…). Since PHPUnit 10, test metadata is declared with attributes; PHPUnit 12 removed support for docblock annotations (@test, @dataProvider) entirely.

<?php

declare(strict_types=1);

namespace Tests\Unit;

use InvalidArgumentException;
use Library\FineCalculator;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;

#[CoversClass(FineCalculator::class)]
final class FineCalculatorTest extends TestCase
{
    private FineCalculator $calculator;

    protected function setUp(): void                     // runs before every test: a fresh fixture
    {
        $this->calculator = new FineCalculator(centsPerDay: 25, capCents: 1000);
    }

    #[Test]
    public function noFineWhenReturnedOnTime(): void
    {
        $this->assertSame(0, $this->calculator->fineFor(0));
    }

    /** @return iterable<string, array{int, int}> */
    public static function lateReturns(): iterable       // data providers must be public static
    {
        yield 'one day' => [1, 25];
        yield 'ten days' => [10, 250];
        yield 'capped' => [100, 1000];
    }

    #[Test]
    #[DataProvider('lateReturns')]
    public function finesGrowPerDayUpToTheCap(int $days, int $expected): void
    {
        $this->assertSame($expected, $this->calculator->fineFor($days));
    }

    #[Test]
    public function rejectsNegativeDays(): void
    {
        $this->expectException(InvalidArgumentException::class);
        $this->expectExceptionMessage('cannot be negative');

        $this->calculator->fineFor(-1);
    }
}

Common assertions: assertSame() (strict ===, preferred), assertEquals() (loose, with a readable diff for objects and arrays), assertTrue()/assertFalse(), assertNull(), assertCount(), assertContains(), assertInstanceOf(), assertStringContainsString(), assertJsonStringEqualsJsonString(), assertEqualsWithDelta() for floats. Other useful attributes: #[TestWith([1, 25])] (inline data), #[Group('slow')], #[Depends('…')], #[RequiresPhp('>= 8.5')], #[RequiresPhpExtension('intl')].

Test doubles: stubs and mocks

A stub replaces a dependency and returns canned values; a mock additionally verifies how it was called. createStub() and createMock() generate them from an interface or class:

<?php

declare(strict_types=1);

namespace Tests\Unit;

use Library\Mailer;
use Library\ReminderService;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;

final class ReminderServiceTest extends TestCase
{
    #[Test]
    public function sendsOneReminderPerOverdueLoan(): void
    {
        $mailer = $this->createMock(Mailer::class);
        $mailer->expects($this->exactly(2))                 // verified at the end of the test
            ->method('send')
            ->willReturn(true);

        $sent = (new ReminderService($mailer))->remind([
            ['email' => 'ada@library.example', 'title' => 'Kindred'],
            ['email' => 'bob@library.example', 'title' => 'Solaris'],
        ]);

        $this->assertSame(2, $sent);
    }

    #[Test]
    public function countsOnlySuccessfulSends(): void
    {
        $mailer = $this->createStub(Mailer::class);         // no expectations, only behaviour
        $mailer->method('send')->willReturnOnConsecutiveCalls(true, false);

        $this->assertSame(1, (new ReminderService($mailer))->remind([
            ['email' => 'ada@library.example', 'title' => 'Kindred'],
            ['email' => 'bob@library.example', 'title' => 'Solaris'],
        ]));
    }
}

Prefer stubs and simple hand-written fakes (an in-memory Mailer that records messages) over strict mocks: tests that verify every call break whenever the implementation is refactored. Mock interfaces you own, not third-party classes. Mockery is a popular alternative mocking library, used by Laravel.

phpunit.xml and running tests

<?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"
         failOnRisky="true"
         failOnWarning="true">
    <testsuites>
        <testsuite name="Unit">
            <directory>tests/Unit</directory>
        </testsuite>
    </testsuites>
    <source>
        <include>
            <directory>src</directory>
        </include>
    </source>
</phpunit>
$ vendor/bin/phpunit --testdox
PHPUnit 13.3.6 by Sebastian Bergmann and contributors.

Fine Calculator (Tests\Unit\FineCalculator)
 ✔ No fine when returned on time
 ✔ Fines grow per day up to the cap with data set "one day"
 ✔ Fines grow per day up to the cap with data set "ten days"
 ✔ Fines grow per day up to the cap with data set "capped"
 ✔ Rejects negative days

Reminder Service (Tests\Unit\ReminderService)
 ✔ Sends one reminder per overdue loan
 ✔ Counts only successful sends

OK (7 tests, 9 assertions)

Useful options: --filter rejectsNegative, --testsuite Unit, --group slow, --stop-on-failure, --testdox.

Code coverage

Coverage needs a driver: Xdebug in coverage mode (XDEBUG_MODE=coverage) or PCOV, which is faster but only measures line coverage. The <source> element of phpunit.xml lists the code to measure, and #[CoversClass] limits which code a test is allowed to cover.

XDEBUG_MODE=coverage vendor/bin/phpunit --coverage-text --coverage-html build/coverage
php -d pcov.enabled=1 vendor/bin/phpunit --coverage-clover build/clover.xml    # with PCOV
vendor/bin/pest --coverage --min=80                                            # fail below 80 %

Coverage shows what is not tested; a high percentage does not prove that the tests check anything. Mutation testing (below) answers that question.

Pest

Pest tests are closures registered with test() or it() in files under tests/; $this inside the closure is a PHPUnit TestCase, so every PHPUnit assertion and attribute concept is still available. The expect() API gives readable, chainable expectations. Pest also runs existing PHPUnit test classes, so once it is installed vendor/bin/pest becomes the single runner: plain vendor/bin/phpunit cannot load Pest’s function-style files.

<?php

declare(strict_types=1);

// tests/Unit/FineCalculatorPestTest.php
use Library\FineCalculator;

beforeEach(function () {
    $this->calculator = new FineCalculator(centsPerDay: 25, capCents: 1000);
});

it('charges nothing for on-time returns', function () {
    expect($this->calculator->fineFor(0))->toBe(0);
});

it('charges per day up to the cap', function (int $days, int $expected) {
    expect($this->calculator->fineFor($days))->toBe($expected);
})->with([                                         // a dataset
    'one day' => [1, 25],
    'ten days' => [10, 250],
    'capped' => [100, 1000],
]);

test('negative days are rejected', function () {
    $this->calculator->fineFor(-1);
})->throws(InvalidArgumentException::class, 'cannot be negative');

test('expectations chain', function () {
    expect(['Kindred', 'Solaris'])
        ->toHaveCount(2)
        ->toContain('Kindred')
        ->each->toBeString();                      // higher-order expectation on every element
});

tests/Pest.php configures the suite: pest()->extend(TestCase::class)->in('Feature') binds a base test case (with traits such as Laravel’s RefreshDatabase) to a directory, and expect()->extend() adds custom expectations. Named datasets can be shared across files with dataset('formats', […]).

Architecture tests

Pest’s arch() tests check rules about the code itself: namespaces depend only on what they should, classes are final, strict types are declared, debugging functions are not left behind. Presets (arch()->preset()->php(), ->security(), ->laravel(), ->strict()) bundle common rules.

<?php

declare(strict_types=1);

// tests/Unit/ArchTest.php
arch('source files use strict types')
    ->expect('Library')
    ->toUseStrictTypes();

arch('no debugging helpers are left behind')
    ->expect(['dd', 'dump', 'var_dump', 'die'])
    ->not->toBeUsed();

arch('classes are final')
    ->expect('Library')
    ->classes()
    ->toBeFinal();
$ vendor/bin/pest
   PASS  Tests\Unit\ArchTest
  ✓ source files use strict types
  ✓ no debugging helpers are left behind
  ✓ classes are final

   PASS  Tests\Unit\FineCalculatorPestTest
  ✓ it charges nothing for on-time returns
  ✓ it charges per day up to the cap with dataset "one day"
  ✓ it charges per day up to the cap with dataset "ten days"
  ✓ it charges per day up to the cap with dataset "capped"
  ✓ negative days are rejected
  ✓ expectations chain
  ...
  Tests:    16 passed (22 assertions)

Pest also runs the PHPUnit test classes of the same suite, so a project can migrate gradually.

Mutation testing

Mutation testing changes the code under test in small ways (< to <=, + to -, removing a return) and runs the tests against each mutant; a mutant that no test catches reveals an untested behaviour. Pest has it built in (--mutate, configured per test file with covers() or mutates()), and Infection is the standalone tool for PHPUnit projects. Both need a coverage driver.

vendor/bin/pest --mutate --parallel --min=90
vendor/bin/infection --threads=4 --min-msi=80     # for PHPUnit suites

Run against this page’s example with vendor/bin/pest --mutate --everything --covered-only, Pest reports Mutations: 4 untested, 12 tested (a score of 75 %), even though line coverage is 100 %. The untested mutants change the constructor’s default values (25 to 26, 1000 to 1001): every test passes explicit values, so no test notices when the defaults change.

Good practice

  • Test behaviour through the public API, one reason to fail per test, with names that describe the behaviour.

  • Arrange-act-assert: build the fixture, call once, assert; keep fixtures small and local (setUp(), beforeEach).

  • Make time, randomness and I/O injectable (PSR-20 clock, a Randomizer, interfaces for mailers and HTTP clients) so tests are deterministic.

  • Run tests in CI on every change, together with static analysis; use --parallel (ParaTest, Pest) for large suites.

  • Test-driven development (write the failing test first) works well for logic like FineCalculator; framework integration code is often better covered by feature tests.

Where older books and tutorials are outdated

  • Docblock annotations (@test, @dataProvider, @covers, @expectedException) were deprecated in PHPUnit 10/11 and removed in PHPUnit 12; use attributes and expectException().

  • Old PHPUnit versions: books targeting PHPUnit 6-7 (PHP 7.0-7.1) show setUp() without the void return type, getMock() and PHPUnit_Framework_TestCase with underscores (PHPUnit 5 and older).

  • Non-static data providers are no longer allowed (PHPUnit 10+).

  • Pest (2020) and its architecture and mutation testing are absent from every book; it is the default test framework offered by the Laravel installer.

  • <filter><whitelist> in phpunit.xml became <coverage> and then <source>; phpunit --migrate-configuration updates old files.