0

Pest PHP — modern testing syntax for Laravel

Intermediate5 min read·lv-26-009

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
<?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
]);