Testing PHP Applications

Pest Installation And Syntax

Pest is a PHP testing framework with a concise functional syntax and tooling built around developer feedback. It runs on the PHPUnit ecosystem, so PHPUnit concepts such as test isolation, assertions, configuration, coverage, and process exit status still matter.

Pest changes how tests are expressed; it does not remove the engineering work of choosing the right boundary, preparing deterministic state, and diagnosing failures.

Check Compatibility Before Installing

Use the current official installation guide and the repository's Composer constraints. The current Pest major requires PHP 8.3 or newer, but an existing application may be pinned to an older compatible Pest release.

Install Pest as a development dependency rather than globally:

composer require pestphp/pest --dev --with-all-dependencies
./vendor/bin/pest --init
./vendor/bin/pest

Initialization creates Pest's suite bootstrap, normally tests/Pest.php, and a starter test. Review generated files before committing them. In a repository that already has PHPUnit configuration and tests, preserve that working setup and initialize on a branch so the complete suite can be compared before and after.

Commit composer.json, composer.lock, test configuration, and suite code together. CI must install development dependencies for the test job.

Understand The Files

A typical suite contains:

phpunit.xml
 tests/
  Pest.php
  Unit/
  Feature/

phpunit.xml or phpunit.xml.dist still defines concerns such as test suites, environment variables, bootstrap files, coverage source paths, and PHP settings. Pest reads the PHPUnit configuration rather than replacing every PHPUnit facility.

tests/Pest.php configures Pest behavior shared by selected test directories. Keep it focused. Global helpers and hooks can be convenient, but a large implicit bootstrap makes individual tests difficult to understand.

Framework projects may connect feature tests to a project base test case:

PHP example
<?php

pest()
    ->extend(Tests\TestCase::class)
    ->in('Feature');

This makes the framework test case available to files under tests/Feature. Unit tests that do not need the framework can remain lightweight instead of booting the full application.

Write A First Test

Pest provides test() and it() forms. They register tests with readable descriptions:

PHP example
<?php

declare(strict_types=1);

function discount(int $totalPence): int
{
    return $totalPence >= 10_000 ? intdiv($totalPence, 10) : 0;
}

test('order receives ten percent discount at threshold', function (): void {
    expect(discount(10_000))->toBe(1_000);
});

it('does not discount an order below the threshold', function (): void {
    expect(discount(9_999))->toBe(0);
});

it() prefixes the displayed description with "it"; it does not provide a different test type. Choose one style consistently enough that the suite remains easy to scan.

Test labels should describe behavior and the important condition. Labels such as works, test discount, or case 1 provide little help when CI reports a failure.

describe() groups tests without requiring a test class:

PHP example
<?php

describe('discount threshold', function (): void {
    test('rejects a subtotal below the threshold', function (): void {
        expect(discount(9_999))->toBe(0);
    });

    test('applies ten percent at the threshold', function (): void {
        expect(discount(10_000))->toBe(1_000);
    });
});

Use grouping when it improves navigation or applies meaningful scoped setup. Do not reproduce a deeply nested application namespace in describe() blocks.

Prepare State With Hooks

Pest supports hooks such as beforeEach(), afterEach(), beforeAll(), and afterAll().

PHP example
<?php

beforeEach(function (): void {
    $this->basketTotalPence = 0;
});

test('starts with an empty basket', function (): void {
    expect($this->basketTotalPence)->toBe(0);
});

beforeEach() runs for every affected test and can use the current test instance. beforeAll() runs once for its scope and does not have a test instance through $this.

Prefer beforeEach() for mutable state so tests remain independent. A shared object created once and modified by several tests makes execution order significant and prevents reliable parallel testing.

Hooks should remove repetitive setup, not hide the reason a test passes. Keep important inputs visible in the test itself.

Run Focused Feedback Loops

The complete command is:

./vendor/bin/pest

During development, narrow the run deliberately:

./vendor/bin/pest tests/Unit/Pricing
./vendor/bin/pest --filter='discount threshold'

A focused command speeds feedback but is not the final verification. Run the repository's complete test command before review, including any Composer wrapper that sets environment variables or runs additional suites.

Pest returns a non-zero status when tests fail, which allows CI to block the change. Do not hide failures with shell commands that always return success.

Keep Production Code Separate

Tiny teaching examples may define a function in the test file. Application tests should load production code through Composer autoloading and import the class or function being tested.

A test should not contain a second implementation of the rule and then prove that duplicate implementation works. Arrange input, call the real production boundary, and assert the observable result.

Add Plugins Deliberately

Pest has plugins for frameworks and additional capabilities. A core Pest installation does not imply that Laravel integration, browser testing, mutation testing, or every reporting feature is installed.

Add a plugin only when the project needs it, check its compatibility with the installed Pest major, commit the lockfile, and include its setup in CI. Plugin code executes with the test process and should receive the same dependency review as other development tooling.

Common Mistakes

  • Installing Pest globally and bypassing the locked project version.
  • Copying an installation command without checking PHP and framework compatibility.
  • Assuming tests/Pest.php replaces phpunit.xml.
  • Booting the framework for every unit test without need.
  • Sharing mutable state through beforeAll() or global variables.
  • Hiding important inputs in broad hooks.
  • Running only a filtered test before opening a pull request.
  • Treating concise syntax as proof that the test boundary is correct.

What To Remember

Install Pest as a project development dependency, preserve PHPUnit configuration that the suite relies on, use tests/Pest.php to apply deliberate shared setup, and keep tests isolated. test(), it(), describe(), and hooks improve expression and organization, but the suite still needs real production calls, deterministic state, and the complete CI command.

Before moving on, make sure you can explain what belongs in tests/Pest.php, why a framework base test case should be limited to the directories that need it, and why a filtered run is not final verification.

Practice

Task: Structure A First Pest Suite

A PHP 8.5 application already has phpunit.xml, Composer autoloading, and a framework base class at Tests\TestCase. The pricing rule is implemented by App\Pricing\DiscountCalculator.

Design the initial Pest setup and tests. Include:

  • project-local installation and initialization commands
  • the role of phpunit.xml after Pest is installed
  • a tests/Pest.php rule that applies Tests\TestCase only to Feature
  • two unit tests for totals of 9999 and 10000 pence
  • a descriptive describe() group
  • an explanation of why the unit tests should not boot the framework
  • one focused development command and the complete final command

The calculator returns zero below 10000 pence and ten percent at or above the threshold. Do not reimplement the calculator inside the test file.

Show solution
composer require pestphp/pest --dev --with-all-dependencies
./vendor/bin/pest --init

Keep phpunit.xml; Pest continues to use its bootstrap, environment, suite, and coverage configuration.

Configure only framework-facing tests in tests/Pest.php:

PHP example
<?php

pest()
    ->extend(Tests\TestCase::class)
    ->in('Feature');

The unit test calls the production calculator through Composer autoloading:

PHP example
<?php

declare(strict_types=1);

use App\Pricing\DiscountCalculator;

describe('discount threshold', function (): void {
    test('returns zero below the threshold', function (): void {
        $calculator = new DiscountCalculator();

        expect($calculator->forTotal(9_999))->toBe(0);
    });

    test('returns ten percent at the threshold', function (): void {
        $calculator = new DiscountCalculator();

        expect($calculator->forTotal(10_000))->toBe(1_000);
    });
});

These tests need only one PHP object, so booting the framework would add time and hidden dependencies without proving more behavior. Framework integration belongs in tests/Feature where the configured base test case is useful.

Use a focused command while editing:

./vendor/bin/pest tests/Unit/Pricing

Before review, run the complete repository command:

./vendor/bin/pest