Pest PHP Testing Framework: Complete Tutorial

Pest PHP Testing Framework: Complete Tutorial

Pest is a modern PHP testing framework built on top of PHPUnit. It offers a simpler, more expressive syntax with test(), it(), and expect() — inspired by Jest and RSpec. Pest is Laravel's default testing framework since Laravel 11. This guide covers setup, writing tests, datasets, architecture testing, and migrating from PHPUnit.

Key Takeaways

Pest wraps PHPUnit. All PHPUnit functionality is available. Pest just provides a nicer syntax layer. You can mix PHPUnit-style and Pest-style tests in the same project.

expect($value)->toBe($expected) is the assertion style. Pest's fluent expect() API is inspired by Jest. It chains assertions: expect($user)->toBeInstanceOf(User::class)->and($user->name)->toBe('Alice').

Datasets replace data providers. dataset('emails', [...]) is the Pest equivalent of PHPUnit #[DataProvider]. Inline datasets keep data close to the test.

Pest Architecture lets you enforce code structure. arch()->expect('App\Models')->toUseStrictTypes() — write rules about your codebase and fail the test suite if they're violated.

beforeEach() and afterEach() replace setUp/tearDown. No need to write a class — just call these at the top of your test file or inside describe() blocks.

What Is Pest?

Pest is a PHP testing framework created by Nuno Maduro (a Laravel core team member) and released in 2021. It's built on PHPUnit but replaces the class-based test syntax with a functional API.

Pest is:

  • The default in Laravel 11+ (via php artisan make:test --pest)
  • Compatible with all PHPUnit plugins (Mockery, mock objects)
  • Available for any PHP project, not just Laravel
  • Faster to write than PHPUnit for simple tests

Pest compiles to PHPUnit at runtime, so CI integration, code coverage, and IDE support work the same way.

Installing Pest

composer require pestphp/pest --dev --with-all-dependencies

# For Laravel projects
composer require pestphp/pest-plugin-laravel --dev

<span class="hljs-comment"># Initialize Pest
./vendor/bin/pest --init

This creates:

tests/
├── Pest.php          # global configuration, uses(), expectations
├── Unit/
│   └── ExampleTest.php
└── Feature/
    └── ExampleTest.php

Your First Pest Test

<?php
// tests/Unit/CalculatorTest.php

test('add returns the sum of two numbers', function () {
    $calculator = new Calculator();

    expect($calculator->add(2, 3))->toBe(5);
});

it('returns zero when adding negative and positive of same value', function () {
    $calculator = new Calculator();

    expect($calculator->add(-5, 5))->toBe(0);
});

test() and it() are identical — it() reads more naturally with behavior descriptions ("it returns zero...").

Run:

./vendor/bin/pest

The expect() Assertion API

Pest's expect() replaces $this->assertX():

Basic Assertions

expect($value)->toBe(5);              // strict equality (===)
expect($value)->toEqual(5);           // loose equality (==)
expect($value)->not->toBe(0);
expect($value)->toBeNull();
expect($value)->not->toBeNull();
expect($value)->toBeTrue();
expect($value)->toBeFalse();

Numeric

expect($price)->toBeGreaterThan(0);
expect($discount)->toBeLessThanOrEqualTo(100);
expect($ratio)->toBeGreaterThanOrEqualTo(0.0)->toBeLessThanOrEqualTo(1.0);
expect($pi)->toEqual(3.14159)->toBeFloat();

Strings

expect($message)->toContain('error');
expect($path)->toStartWith('/api/');
expect($email)->toEndWith('@example.com');
expect($date)->toMatch('/^\d{4}-\d{2}-\d{2}$/');
expect($text)->not->toBeEmpty();
expect($name)->toHaveLength(5);

Arrays

expect($list)->toBeArray();
expect($list)->toHaveCount(3);
expect($list)->toContain('apple');
expect($list)->not->toContain('banana');
expect($list)->toHaveKey('email');
expect($list)->toMatchArray(['name' => 'Alice', 'role' => 'admin']);
expect($list)->toBeEmpty();

Types and Instances

expect($user)->toBeInstanceOf(User::class);
expect($result)->toBeString();
expect($count)->toBeInt();
expect($flag)->toBeBool();
expect($items)->toBeArray();

Exceptions

expect(fn() => $service->createUser([]))->toThrow(ValidationException::class);

expect(fn() => $service->createUser([]))
    ->toThrow(ValidationException::class, 'Email is required');

Chaining

expect($user)
    ->toBeInstanceOf(User::class)
    ->and($user->name)->toBe('Alice')
    ->and($user->email)->toEndWith('@example.com')
    ->and($user->isActive())->toBeTrue();

Hooks: Setup and Teardown

// Runs before each test in this file
beforeEach(function () {
    $this->service = new UserService(
        new InMemoryUserRepository()
    );
});

// Runs after each test
afterEach(function () {
    // cleanup
});

// Runs once before all tests in this file
beforeAll(function () {
    // expensive one-time setup
});

// Runs once after all tests
afterAll(function () {
    // cleanup
});

test('create user returns user object', function () {
    // $this->service is available from beforeEach
    $user = $this->service->create(['email' => 'alice@example.com']);
    expect($user)->toBeInstanceOf(User::class);
});

Grouping Tests with describe()

describe('UserService', function () {
    beforeEach(function () {
        $this->service = new UserService(new InMemoryUserRepository());
    });

    describe('create', function () {
        it('creates a user with valid data', function () {
            $user = $this->service->create(['email' => 'alice@example.com', 'name' => 'Alice']);
            expect($user->email)->toBe('alice@example.com');
        });

        it('throws on duplicate email', function () {
            $this->service->create(['email' => 'alice@example.com']);
            expect(fn() => $this->service->create(['email' => 'alice@example.com']))
                ->toThrow(DuplicateEmailException::class);
        });
    });

    describe('delete', function () {
        it('soft deletes the user', function () {
            $user = $this->service->create(['email' => 'alice@example.com']);
            $this->service->delete($user->id);
            expect($this->service->find($user->id))->toBeNull();
        });
    });
});

Datasets (Data Providers)

Datasets replace PHPUnit's #[DataProvider]:

Inline Dataset

it('validates email format', function (string $email, bool $expected) {
    expect(EmailValidator::isValid($email))->toBe($expected);
})->with([
    ['user@example.com', true],
    ['not-an-email', false],
    ['user@', false],
    ['@domain.com', false],
    ['user+tag@example.com', true],
]);

Named Dataset

dataset('invalid_emails', [
    'missing @'       => ['not-an-email'],
    'missing domain'  => ['user@'],
    'missing user'    => ['@domain.com'],
    'empty string'    => [''],
]);

it('rejects invalid emails', function (string $email) {
    expect(EmailValidator::isValid($email))->toBeFalse();
})->with('invalid_emails');

Define reusable datasets in tests/Pest.php:

// tests/Pest.php
dataset('user_roles', ['admin', 'editor', 'viewer']);

Combining Datasets

it('rejects all invalid emails across all roles', function (string $email, string $role) {
    // runs for every combination of email × role
})->with('invalid_emails', 'user_roles');

Higher-Order Tests

When you just need to call a method and assert the result, Pest has a shorthand:

// Instead of:
test('user can be deleted', function () {
    expect($this->user->delete())->toBeTrue();
});

// Higher-order:
it('can be deleted')->assertTrue(fn() => $this->user->delete());

Most useful with custom expectations and Pest plugins.

Skipping and Todo Tests

test('payment integration', function () {
    // ...
})->skip('Stripe test mode credentials not available in this environment');

// Skip conditionally
test('windows-specific behavior', function () {
    // ...
})->skipOnWindows();

// Mark as todo (no implementation yet — fails if it actually passes)
test('refund flow is idempotent')->todo();

Groups and Filtering

test('it processes orders', function () {
    // ...
})->group('orders', 'integration');

test('it sends emails', function () {
    // ...
})->group('email', 'integration');

Run by group:

./vendor/bin/pest --group integration
./vendor/bin/pest --group orders

Architecture Testing

Pest's arch() helper lets you write tests that enforce codebase structure:

arch('models use strict types')
    ->expect('App\Models')
    ->toUseStrictTypes();

arch('controllers do not use repositories directly')
    ->expect('App\Http\Controllers')
    ->not->toUse('App\Repositories');

arch('services only use interfaces, not concrete classes')
    ->expect('App\Services')
    ->not->toUse('App\Infrastructure');

arch('no debugging functions in production code')
    ->expect('App')
    ->not->toUse(['dd', 'dump', 'var_dump', 'print_r', 'die', 'exit']);

Architecture tests run as part of your test suite and fail the build if violations are found.

Testing with Laravel

For Laravel, add the Pest plugin:

// tests/Pest.php
<?php

use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

uses(TestCase::class, RefreshDatabase::class)->in('Feature');
uses(TestCase::class)->in('Unit');

Now all tests in Feature/ extend Laravel's TestCase and use RefreshDatabase:

// tests/Feature/ProductsTest.php
<?php

use App\Models\Product;
use App\Models\User;

test('authenticated user can view products', function () {
    $user = User::factory()->create();

    $this->actingAs($user)
         ->get('/api/products')
         ->assertOk()
         ->assertJsonStructure(['data' => [['id', 'name', 'price']]]);
});

test('product creation validates required fields', function () {
    $user = User::factory()->admin()->create();

    $this->actingAs($user)
         ->post('/api/products', [])
         ->assertUnprocessable()
         ->assertJsonValidationErrors(['name', 'price']);
});

Datasets with Laravel:

it('returns correct HTTP status for each role', function (string $role, int $expectedStatus) {
    $user = User::factory()->create(['role' => $role]);

    $this->actingAs($user)->get('/admin')->assertStatus($expectedStatus);
})->with([
    ['admin', 200],
    ['editor', 200],
    ['viewer', 403],
    ['guest', 302], // redirect to login
]);

Migrating from PHPUnit to Pest

Pest can run alongside PHPUnit tests. You don't need to migrate everything at once.

# Check that existing PHPUnit tests still pass under Pest
./vendor/bin/pest

Mechanical conversion:

// PHPUnit
class UserServiceTest extends TestCase
{
    private UserService $service;

    protected function setUp(): void
    {
        $this->service = new UserService(new MockRepository());
    }

    public function test_create_user_returns_user(): void
    {
        $user = $this->service->createUser(['email' => 'a@b.com']);
        $this->assertInstanceOf(User::class, $user);
    }
}

// Pest equivalent
beforeEach(fn() => $this->service = new UserService(new MockRepository()));

test('create user returns user', function () {
    $user = $this->service->createUser(['email' => 'a@b.com']);
    expect($user)->toBeInstanceOf(User::class);
});

Running Tests

# All tests
./vendor/bin/pest

<span class="hljs-comment"># Specific file
./vendor/bin/pest tests/Unit/UserServiceTest.php

<span class="hljs-comment"># Filter by name
./vendor/bin/pest --filter <span class="hljs-string">"user can login"

<span class="hljs-comment"># Run a group
./vendor/bin/pest --group integration

<span class="hljs-comment"># Parallel (requires ext-parallel or Pest Pro)
./vendor/bin/pest --parallel

<span class="hljs-comment"># Coverage
./vendor/bin/pest --coverage --min=80

CI/CD Integration

name: Pest Tests

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: shivammathur/setup-php@v2
        with:
          php-version: '8.3'
          coverage: xdebug

      - run: composer install --no-interaction

      - name: Run Pest
        run: ./vendor/bin/pest --coverage-clover coverage.xml

      - uses: codecov/codecov-action@v4
        with:
          files: coverage.xml

Pest vs PHPUnit

Aspect Pest PHPUnit
Syntax Functional (test(), it()) Class-based (testX())
Assertions expect($val)->toBe(5) $this->assertEquals(5, $val)
Setup beforeEach(fn() => ...) setUp() method
Data providers ->with([...]) #[DataProvider]
Architecture arch()->expect(...) Not available
Laravel default Yes (v11+) Was default before v11
Underlying engine PHPUnit PHPUnit

Choose Pest for new PHP and Laravel projects. Keep PHPUnit for existing projects or when your team prefers the class-based model. Both work identically in CI.

HelpMeTest Integration

Pest handles your PHP unit and feature test coverage. For end-to-end browser testing — Playwright automating a real browser against your deployed app — HelpMeTest runs Robot Framework tests with 24/7 monitoring.

Your Pest tests catch PHP logic bugs in CI. HelpMeTest catches deployment issues, third-party failures, and UI regressions that no in-process test can find.

Read more