<!--
  canonical-url: https://www.avonture.be/blog/pest_tips
  published:     2024-09-27
  This is a static plain-Markdown mirror generated at build time.
  Visit the canonical URL above for the fully rendered page, with images and interactive components.
-->

# 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.

---
![Write PHP unit tests using Pest](/img/v2/unit_tests.webp)

**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](/blog/php-jakzal-phpqa) and [pre-commit hooks](/blog/git-precommit), 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](/blog/pest-functional-testing). Testing Bash scripts? Check out [Running unit tests with bats/bats](/blog/bats-unit-tests).

## Result

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

**tests/Pest.php**

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

**tests/Feature/MyFirstTest.php**

```php
<?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()->`
});
```

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

```bash
   PASS  Tests\Feature\MyFirstTest
  ✓ assert true is true
  ✓ assert false is not true

  Tests:  2 passed
  Time:   0.08s
```

## 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:

```bash
$ composer require pestphp/pest --dev --with-all-dependencies

$ composer require pestphp/pest-plugin-laravel --dev

$ php artisan pest:install

$ ./vendor/bin/pest --init
```

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](https://marketplace.visualstudio.com/items?itemName=bmewburn.vscode-intelephense-client) and enjoy the autocomplete feature of vscode.

#### Difference between toBe and toEqual

**tests/Feature/AnyTest.php**

```php
<?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
});
```

`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.

**tests/pest.php**

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

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.

**tests/Feature/AnyTest.php**

```php
<?php

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

### Using datasets

> <https://pestphp.com/docs/datasets>

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

Here is [inline](https://pestphp.com/docs/datasets#inline-datasets)

**tests/Feature/AnyTest.php**

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

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

**tests/Feature/AnyTest.php**

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

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

**tests/Feature/AnyTest.php**

```php
<?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);
    }
}
```

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:

**tests/Feature/AnyTest.php**

```php
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 part can be seen on video <https://youtu.be/9EGPo_enEc8?t=1021>

We can also check if a class is final:

**tests/Feature/AnyTest.php**

```php
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),
```

### 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).

**tests/Feature/AnyTest.php**

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

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.

**tests/Feature/AnyTest.php**

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

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

**tests/Feature/AnyTest.php**

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

And also in the `tests/Unit` folder:

**tests/Feature/AnyTest.php**

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

## 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:

**tests/Feature/AnyTest.php**

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

## Convert from PHPUnit

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

> [Migrate from PHPUnit to Pest - Online migration tool](https://benjamincrozat.com/phpunit-to-pest)

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

**tests/Feature/example\_test.php**

```php
<?php

namespace Tests\Unit;

use PHPUnit\Framework\TestCase;

class ExampleTest extends TestCase
{
    public function test_that_true_is_true(): void
    {
        $this->assertTrue(true);
    }
}
```

will be converted to

**tests/Feature/example\_test.php**

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

## Tools

### Laravel plugin

> [Laravel Pest plugin](https://pestphp.com/docs/plugins/laravel)

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

Then some new artisan commands will be available:

```bash
$ php artisan | grep pest
 pest
  pest:dataset           Create a new dataset file
  pest:install           Creates Pest resources in your current PHPUnit test suite
  pest:test              Create a new test file
```

### Visual Studio Code Add-on

* If not yet installed, [PHP Intelephense](https://marketplace.visualstudio.com/items?itemName=bmewburn.vscode-intelephense-client) will allow you to press `F12` on a method name (like `toBeTrue`) and jump where the method is implemented,
* [Better Pest](https://marketplace.visualstudio.com/items?itemName=m1guelpf.better-pest) and
* [Pest Snippets](https://marketplace.visualstudio.com/items?itemName=dansysanalyst.pest-snippets)

#### Better Pest with Docker

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

**.vscode/settings.json**

```json
{
    "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"
    }
}
```

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.

## Links

### Videos

* [Laracast - Pest](https://laracasts.com/series/jeffreys-larabits/episodes/30)
* [Laracast - Pest from Scratch](https://laracasts.com/series/pest-from-scratch)
* [Laracon IN 2023: the future of PEST](https://youtu.be/9EGPo_enEc8)
