Pest PHP — modern testing syntax for Laravel
Concept
Pest PHP is a modern testing framework that runs on top of PHPUnit. It provides a cleaner, more expressive API using functions instead of classes. All Laravel testing features (HTTP tests, Eloquent assertions, fakes) work identically in Pest.
Installation: composer require pestphp/pest pestphp/pest-plugin-laravel --dev && php artisan pest:install.
Functions instead of classes:
test('description', function() { ... }): A test.it('does something', function() { ... }): Alternative (reads like BDD: "it does something").describe('Group', function() { ... }): Group related tests. Can nest.beforeEach(function() { ... }): Run before each test in scope.afterEach(function() { ... }): Run after each test in scope.
$this in Pest tests: With uses(TestCase::class), $this inside closures is the TestCase. Needed for $this->actingAs(), $this->post(), etc.
uses() function: Declare traits and base classes for the test file or directory:
uses(TestCase::class, RefreshDatabase::class)— applies to current file.- In
Pest.php(root config): set global uses.
Expectations API: Pest has a fluent expect() API as an alternative to PHPUnit's assert*() methods:
expect($value)->toBe(1),->toBeTrue(),->toBeNull(),->toContain('text').expect($model)->toExist()(with Pest-Laravel plugin).
Datasets: it('processes', function($input, $expected) { ... })->with([[1, 2], [3, 6]]) — runs the test with each dataset row. Replaces PHPUnit @dataProvider.
Code Example
<?php
// tests/Feature/PostTest.php
use App\Models\Post;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
// Basic test
test('guests cannot see admin posts', function() {
$post = Post::factory()->create(['status' => 'draft']);
$this->get("/posts/{$post->slug}")->assertNotFound();
});
// it() style — BDD
it('allows authenticated users to create posts', function() {
$user = User::factory()->create();
$this->actingAs($user)->postJson('/api/posts', [
'title' => 'Test Post',
'body' => 'Content.',
])->assertCreated();
expect(Post::count())->toBe(1);
});
// beforeEach — shared setup
describe('post management', function() {
beforeEach(function() {
$this->user = User::factory()->create();
$this->post = Post::factory()->for($this->user)->create();
});
it('can be edited by the author', function() {
$this->actingAs($this->user)
->patch("/posts/{$this->post->id}", ['title' => 'Updated'])
->assertRedirect();
expect($this->post->fresh()->title)->toBe('Updated');
});
it('cannot be edited by others', function() {
$other = User::factory()->create();
$this->actingAs($other)
->patch("/posts/{$this->post->id}", ['title' => 'Hacked'])
->assertForbidden();
});
});
// Datasets — run same test with multiple inputs
it('validates title minimum length', function(string $title, bool $shouldPass) {
$user = User::factory()->create();
$response = $this->actingAs($user)->postJson('/api/posts', ['title' => $title, 'body' => 'text']);
$shouldPass ? $response->assertCreated() : $response->assertUnprocessable();
})->with([
['ab', false], // too short
['abc', true], // minimum length
['long title', true], // fine
]);