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 --initThis creates:
tests/
├── Pest.php # global configuration, uses(), expectations
├── Unit/
│ └── ExampleTest.php
└── Feature/
└── ExampleTest.phpYour 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/pestThe 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 ordersArchitecture 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/pestMechanical 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=80CI/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.xmlPest 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.