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}}).
Source: PHPUnit — Installation and Pest — Installation
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.
Source: PHPUnit — Writing Tests
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')].
Source: PHPUnit — Attributes and PHPUnit — Assertions
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.
Source: PHPUnit — Test Doubles
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.
Source: PHPUnit — Code Coverage
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', […]).
Source: Pest — Writing Tests, Pest — Expectations and Pest — Datasets
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.
Source: Pest — Architecture Testing
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 andexpectException(). -
Old PHPUnit versions: books targeting PHPUnit 6-7 (PHP 7.0-7.1) show
setUp()without thevoidreturn type,getMock()andPHPUnit_Framework_TestCasewith 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>inphpunit.xmlbecame<coverage>and then<source>;phpunit --migrate-configurationupdates old files.