Skip to main content

Write PHP unit tests using Pest

Master PHP unit testing with Pest! Discover easy installation, the expressive expect syntax, datasets, snapshots, and powerful architectural testing features.

· 9 min read
View raw
Christophe
Markdown, WSL and Docker lover ~ Insatiable curious.
Cet article existe aussi en français — lire la version française.

Changelog

Latest
Pest v5 released at Laracon US 2026 (requires PHP 8.4 + PHPUnit 13); core unit-testing API unchanged.

Write PHP unit tests using Pest

⚡
TL;DR

This is a broad reference for Pest, the expressive PHPUnit wrapper for PHP: installation, the it()/test()/expect() syntax, assertions vs expectations, inline and shared datasets, reusing existing PHPUnit test cases, architectural tests (enforcing rules like "no validation in controllers"), snapshot testing, custom global functions in tests/Pest.php, converting from PHPUnit, and VSCode tooling (Better Pest, Pest Snippets) including Docker-container setup.

If you think writing unit tests in PHP is fun, stay in the room; the rest of you please leave. And then everyone leaves, including the person who asked the question.

Writing old-fashioned unit tests with PHPUnit is so boring that almost nobody does it.

And for some time now, https://pestphp.com/ has come along and totally changed the way things are done.

Pest is a wrapper around PhpUnit so, for instance, every command-line argument supported by PhpUnit can be used for Pest.

Unit tests are one layer of a quality strategy; the others on this blog are static analysis with jakzal/phpqa and pre-commit hooks, which run both before a commit can pass.

Looking for browser/functional testing instead?

This article focuses on classic unit tests. Pest v4 added built-in browser testing; Pest v5 (current, July 2026) adds Test Impact Analysis and requires PHP 8.4 — see Writing functional tests with Pest. Testing Bash scripts? Check out Running unit tests with bats/bats.

Result​

A test file, and the bootstrap line that wires it to PHPUnit's TestCase:

uses(Tests\TestCase::class)->in('Features');

This line configures a test suite to use the TestCase class from the Tests namespace and apply it to all test files in the 'Features' directory. It's a setup instruction that tells the testing framework how to run feature tests.

<?php
test('assert true is true', function () {
expect(true)->toBeTrue();
});
test('assert false is not true', function () {
expect(false)->not->toBeTrue(); // we can also write `not()->`
});

This is a test file using a testing framework (likely Pest or PHPUnit) that runs two simple assertion tests. The first test verifies that the boolean value true is indeed true, and the second test verifies that false is not true. These are basic smoke tests to confirm the testing framework itself is working correctly.

Running it with ./vendor/bin/pest tests/Feature/MyFirstTest.php gives:

>user@machine: ~/yourproject

Why Pest​

  • Tests read like natural sentences: expect($value)->toBeTrue() instead of PHPUnit's $this->assertTrue($value) — methods can be negated with not->.
  • it() and test() are interchangeable; use whichever fits your naming convention, both share the same behavior and syntax.
  • Pest is a wrapper around PHPUnit, not a replacement: every PHPUnit command-line argument still works, and existing PHPUnit test cases can be reused as-is (just add /** @test */).

Installation​

https://pestphp.com/docs/installation#installation

Simply run the commands below in your PHP application folder:

>user@machine: ~/yourproject

From now, we can run ./vendor/bin/pest to run our Pest tests.

Writing tests​

Introduction about Pest​

Files should have the Test suffix​

Just like PHPUnit, Pest will process every files in folders tests/Feature and tests/Unit having the Test suffix like f.i. ShoppingBasketTest.php.

What does $this mean in a test?​

In our tests/Pest.php file, we've the bootstrap line shown at the top of this article.

In a Pest test, $this refers to the PHPUnit Tests\TestCase class.

it or test​

Pest gives us the choice between it() and test(). Use the one that best fits your test naming convention, or both. They share the same behavior & syntax.

Read more: https://pestphp.com/docs/writing-tests#api-reference

The result is the same, just how the output is done on the console.

Our first tests​

That's the MyFirstTest.php test and its PASS output shown at the top of this article. It illustrates that Pest starts with an expect verb and some method like toBeTrue(). Methods can be negated using not-> (https://pestphp.com/docs/expectations#expect-not).

Autocomplete​

Make sure to install and enable PHP Intelephense and enjoy the autocomplete feature of vscode.

Difference between toBe and toEqual​

<?php
test('assert count is correct', function () {
expect(2 + 2)->toBe(4); // Will be true
expect(2 + 2)->toBe('4'); // Will NOT be true
expect(2 + 2)->toEqual(4); // Will be true
expect(2 + 2)->toEqual('4'); // Will be true
});

This test demonstrates the difference between two assertion methods in a testing framework: `toBe` checks both the value and type (strict equality), while `toEqual` checks only the value (loose equality). The test shows that 4 and '4' are equal in value but not identical in type.

toBe will be stricter i.e. will check both the value and the data type when, toEqual will just check the value.

Assertions​

https://pestphp.com/docs/assertions

Assertions come from PhpUnit and work the same way.

Assertions are accessible through the $this object and this because tests/pest.php contains the line below.

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

This tells a testing framework (Pest PHP) to use the TestCase class from the Tests namespace for all test files in the Feature directory. It's a configuration line that sets up the foundation that every feature test will inherit from.

So $this refers to the Tests\TestCase PHPUnit class.

Expectations​

https://pestphp.com/docs/expectations

In addition to assertions, Pest offers you a set of expectations. These functions let you test your values against certain conditions. This API is inspired by Jest. Expectations also allow you to write your tests like you would a natural sentence.

Assertions and expectations can be used in Pest tests files but ... expectations are more explicit and intuitive.

<?php
test('assert true is true', function () {
// These two lines do exactly the same. Keep just one...
$this->assertTrue(true);
expect(true)->toBeTrue();
});

This is a simple test case that verifies the boolean value true evaluates as true. It shows two equivalent ways to make this assertion in PHP testing—one using an older method and one using a newer, more modern syntax. The comment indicates that only one of these assertion lines should be kept since they accomplish the same thing.

Using datasets​

https://pestphp.com/docs/datasets

We've multiple way to provide data to a function.

Here is inline

it('has emails', function (string $email) {
expect($email)->not->toBeEmpty();
})->with([
'enunomaduro@gmail.com',
'other@example.com'
]);

This is a test that verifies email addresses are not empty. The test runs twice—once with each email address provided in the array—to ensure both test cases pass the validation.

The dataset is then an array and we can have a multi-dimension array:

it('has emails', function (string $name, string $email) {
expect($email)->not->toBeEmpty();
})->with([
['John', 'enunomaduro@gmail.com'],
['Other', 'other@example.com']
]);

This is a parameterized test that checks whether email values are not empty. The test runs twice—once with each row of data provided in the `with()` method—verifying that the email parameter in each case passes the assertion.

There is also a way to create a shared dataset which is probably better when the test file becomes big (https://pestphp.com/docs/datasets#shared-datasets).

Reuse PHPUnit tests cases without changes​

This is damned simple: we just need to add /** @test */ as the doc block before the test scenario.

For instance

<?php
declare(strict_types=1);
namespace Tests\Feature;
use Tests\TestCase;
class VisitLoginPageTest extends TestWebCase
{
/** @test */
public function test_we_should_see_fields_email_and_password()
{
$this->response->assertSee('id="password"', false);
$this->response->assertSee('id="email"', false);
}
}

This is a feature test that verifies a login page renders correctly by checking that email and password input fields are present in the HTML response. It's a simple smoke test to ensure the login form displays the expected form elements.

And from now that test can be fired using ./vendor/bin/pest.

Architectural tests​

https://pestphp.com/docs/arch-testing

Using Pest (as from v2), we can ensure some architectural consistencies, like not using validations in a controller (using $request->validate(...)) but forcing the use of Form request control classes instead.

The architectural plugin will not help to fire unit tests but will scan the project and will ensure some rules are followed.

Architectural tests can be:

test('controllers')
->expect('App\Http\Controllers')
->not->toUse('Illuminate\Http\Request');
// Models can only be used in a repository
test('models')
->expect('App\Models')
->toOnlyBeUsedOn('App\Repositories')
->toOnlyUse('Illuminate\Database');
test('repositories')
->expect('App\Repositories')
->toOnlyBeUsedOn('App\Controllers')
->toOnlyUse('App\Models');
test('globals')
->expect(['dd','dump','var_dump'])
->not->toBeUsed();
test('facades')
->expect('Illuminate\Support\Facades')
->not->toBeUsed()
->ignoring('App\Providers');

This is a set of architecture tests that enforce code organization rules for a Laravel application. It ensures that different parts of the codebase (controllers, models, repositories, etc.) only interact with each other in specific, allowed ways — for example, models should only be used in repositories, and certain debug functions should never be used in production code.

This part can be seen on video https://youtu.be/9EGPo_enEc8?t=1021

We can also check if a class is final:

test('controllers')
->expect('App\Http\Controllers')
->toUseStrictTypes()
->toHaveSuffix('Controller') // or toHavePrefix, ...
->toBeReadonly()
->toBeClasses() // or toBeInterfaces, toBeTraits, ...
->classes->toBeFinal() // 🌶
->classes->toExtendNothing() // or toExtend(Controller::class),
->classes->toImplementNothing() // or toImplement(ShouldQueue::class),

This is a code quality test that checks whether all controller classes in the App\Http\Controllers namespace follow a strict set of architectural rules. It verifies they use strict types, end with 'Controller' in their name, are marked as readonly, are actual classes (not interfaces or traits), are final, and don't extend or implement anything unnecessarily.

Taking snapshots​

There is also a feature called Snapshots. The idea is to store a content as a snapshot then compares future run with that snapshot.

A snapshot can be the content of an HTML page, a JSON answer, the content of a file / array, ... everything in fact (for an object; we can serialize it so we can store it too as a snapshot).

it('has a welcome page', function() {
$response = $this->get('/');
expect($response)->toMatchSnapshot();
});

This test checks that when someone visits the root URL of the application, the response matches a previously saved snapshot. A snapshot is like a photograph of the expected output — if the page changes unexpectedly, the test will catch it by comparing the new output to what was saved before.

On the very first run (vendor/bin/pest), the snapshot didn't exist yet so it'll be created on disk and the test will be noted as WARN.

The snapshot will be created in a subdirectory in the ./tests/.pest/snapshots folder (the subdirectory will match the location of your fired test (f.i. Feature/ExampleTest/it_has_a_welcome_page.snap)).

As from the second run, the taken snapshot will then be compared with, in the example here above, the HTML content of the homepage. As soon as a difference is noted (like the today date if present on the page), Pest will show it in a diff: the previous string coming from the snapshot and the retrieved, actual, string.

Write global functions​

We can write our own custom functions in the tests/Pest.php file.

Pest bootstrap​

The file tests/pest.php can be used to place there global function but we'll also need to update it if, inside our tests files, we need some other classes.

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

This configures a test suite to use the TestCase class from the Tests namespace for all test files in the Feature directory. It's a setup line that tells the test runner which base class to inherit from and which folder to apply it to.

The line above will make Tests\TestCase available in all tests in tests/Feature. If we need more classes, we can add them:

uses(Tests\TestCase::class,Illuminate\Foundation\Testing\RefreshDatabase::class)->in('Feature');

This configures PHPUnit to use two traits for all feature tests in the Feature directory. The TestCase trait provides basic test utilities, and RefreshDatabase automatically resets the database before each test so tests don't interfere with each other.

And also in the tests/Unit folder:

uses(Tests\TestCase::class)->in('Unit');

This line configures a testing framework (likely Pest) to use the TestCase class from the Tests namespace for all test files in the 'Unit' directory. It's a setup instruction that tells the test runner how to prepare the environment before running unit tests.

Tips and tricks​

Optional.

Dump and die​

We can use the dd method to dump the current expectation value and end the test suite like this:

expect($response)
->dd()
->toHaveKey('data')
->data->toBeEmpty();

This is a test assertion that checks two things about a response object: first, that it contains a key called 'data', and second, that whatever value is at that 'data' key is empty. The `dd()` call in the middle is a debugging helper that dumps the response if the test fails, so you can see what actually came back.

Convert from PHPUnit​

Optional — only relevant if you have an existing PHPUnit suite to migrate.

Migrate from PHPUnit to Pest - Online migration tool

Introduced in Pest since v2.9, there is a tool to convert from PHPUnit to Pest.

See https://pestphp.com/docs/pest-spicy-summer-release#content-drift-plugin

Note: Rector has also a tool: https://github.com/rectorphp/rector-pest

For example, the code below

<?php
namespace Tests\Unit;
use PHPUnit\Framework\TestCase;
class ExampleTest extends TestCase
{
public function test_that_true_is_true(): void
{
$this->assertTrue(true);
}
}

This is a basic unit test file that checks whether the value true actually equals true. It's a minimal example showing how to structure a test class using PHPUnit, the PHP testing framework. The test will always pass and serves as a simple verification that the testing setup is working correctly.

will be converted to

test('true is true', function () {
expect(true)->toBeTrue();
});

This is a simple test case that verifies the boolean value `true` is indeed `true`. It's using a testing framework (likely Pest or PHPUnit) to define a test with a human-readable name and then assert that the condition passes.

Tools​

Laravel plugin​

Laravel Pest plugin

Install the plugin like this: composer require pestphp/pest-plugin-laravel --dev

Then some new artisan commands will be available:

>user@machine: ~/yourproject

Visual Studio Code Add-on​

Better Pest with Docker​

If you're using Docker, think to add the next lines in your .vscode/settings.json configuration file:

{
"better-pest.docker.enable": true,
"better-pest.docker.command": "docker compose exec -u root:root app",
"better-pest.docker.paths": {
"/your/local/path": "/your/remote/path"
}
}

This is a configuration file for the Better Pest VS Code extension that tells it how to run tests inside a Docker container. It enables Docker support, specifies the command to execute tests within a running Docker container, and maps local file paths to their corresponding locations inside the container so the tool can find and reference files correctly.

Think to adjust the name of your container (app here) and paths:

  • /your/local/path is where your repository is stored, on your host machine,
  • /your/remote/path is the path in your container, probably /var/www/html.

Now, just open any Pest file and press CTRL+Shift+P to open the Command Palette. Start to type Better Pest and select the desired option (like Better Pest: run for running the file).

Convert from PHPUnit to Pest​

The repository https://github.com/mandisma/pest-converter proposes a PHPUnit to Pest Converter: PestConverter is a PHP library for converting PHPUnit tests to Pest tests.

Conclusion​

Pest doesn't replace PHPUnit, it just replaces the ceremony — expect()->toBeTrue() instead of boilerplate assertions, datasets and architectural tests for free, and every existing PHPUnit test case still works unchanged. Install it once, write the next test in the expressive syntax, and migrate the rest only when it's convenient.

Videos​


Was this article helpful?
Did you try the steps in this article?