Testing & TDD

Pest PHP Framework

15 min Lesson 30 of 35

Pest PHP Framework

Pest is a modern PHP testing framework with a focus on simplicity and elegant syntax. Built on top of PHPUnit, it provides a more expressive and developer-friendly API while maintaining full compatibility with PHPUnit tests.

Why Pest?

Pest offers several advantages over traditional PHPUnit:

  • Beautiful Syntax: Clean, readable test code without boilerplate
  • Expectations API: Expressive assertions that read like natural language
  • Higher-Order Testing: Powerful shortcuts for common test patterns
  • Plugins: Rich ecosystem for Laravel, Livewire, parallel testing, and more
  • TypeScript-like: Inspired by Jest, making it familiar to JavaScript developers
  • Zero Configuration: Works out of the box with sensible defaults

Installation

# Install Pest composer require pestphp/pest --dev --with-all-dependencies # Install Pest for Laravel composer require pestphp/pest-plugin-laravel --dev # Initialize Pest ./vendor/bin/pest --init # Run tests ./vendor/bin/pest
After installation: Pest creates a Pest.php file in your tests directory for global configuration and setup.

Basic Pest Syntax

Your First Pest Test

<?php // tests/Unit/ExampleTest.php test('basic math works', function () { expect(2 + 2)->toBe(4); }); it('can calculate total price', function () { $price = 100; $tax = 15; $total = $price + $tax; expect($total)->toBe(115); });
test() vs it(): Both functions create tests. Use test() for statements ("basic math works") or it() for behaviors ("can calculate total"). Choose based on readability.

PHPUnit vs Pest Comparison

<?php // PHPUnit style class UserTest extends TestCase { use RefreshDatabase; public function test_user_can_be_created() { $user = User::create([ 'name' => 'John Doe', 'email' => 'john@example.com' ]); $this->assertInstanceOf(User::class, $user); $this->assertEquals('John Doe', $user->name); $this->assertDatabaseHas('users', [ 'email' => 'john@example.com' ]); } } // Pest style use function Pest\Laravel\{assertDatabaseHas}; uses(RefreshDatabase::class); it('can create a user', function () { $user = User::create([ 'name' => 'John Doe', 'email' => 'john@example.com' ]); expect($user) ->toBeInstanceOf(User::class) ->name->toBe('John Doe'); assertDatabaseHas('users', ['email' => 'john@example.com']); });

Expectations API

Pest's expectations provide an expressive, chainable API for assertions:

Basic Expectations

<?php test('basic expectations', function () { // Equality expect(10)->toBe(10); expect('hello')->toEqual('hello'); // Boolean checks expect(true)->toBeTrue(); expect(false)->toBeFalse(); expect(null)->toBeNull(); expect('value')->not->toBeNull(); // Type checks expect(100)->toBeInt(); expect('string')->toBeString(); expect([])->toBeArray(); expect(new User)->toBeObject(); // Collections expect([1, 2, 3])->toHaveCount(3); expect(['a', 'b'])->toContain('a'); expect(['key' => 'value'])->toHaveKey('key'); // Numeric comparisons expect(10)->toBeGreaterThan(5); expect(5)->toBeLessThan(10); expect(10)->toBeGreaterThanOrEqual(10); });

String Expectations

<?php test('string expectations', function () { $email = 'test@example.com'; expect($email) ->toBeString() ->toContain('@') ->toStartWith('test') ->toEndWith('.com') ->toMatch('/^[\w.]+@[\w.]+$/'); expect('HELLO') ->toBeUppercase() ->not->toBeLowercase(); $json = '{"name":"John"}'; expect($json)->toBeJson(); });

Object and Instance Expectations

<?php test('object expectations', function () { $user = new User(['name' => 'John']); expect($user) ->toBeInstanceOf(User::class) ->toHaveProperty('name') ->name->toBe('John'); $collection = collect([1, 2, 3]); expect($collection) ->toBeInstanceOf(Collection::class) ->toHaveMethod('map') ->count()->toBe(3); });

Exception Expectations

<?php test('exception expectations', function () { expect(fn() => throw new Exception('Error')) ->toThrow(Exception::class); expect(fn() => throw new Exception('Not found')) ->toThrow(Exception::class, 'Not found'); expect(fn() => User::findOrFail(999)) ->toThrow(ModelNotFoundException::class); });

Datasets

Datasets allow you to run the same test with different input values, similar to PHPUnit's data providers but more elegant:

Basic Datasets

<?php it('validates email addresses', function ($email, $isValid) { $validator = new EmailValidator(); expect($validator->isValid($email))->toBe($isValid); })->with([ ['test@example.com', true], ['invalid-email', false], ['missing@domain', false], ['spaces in@email.com', false], ['valid.email+tag@example.co.uk', true], ]);

Named Datasets

<?php it('calculates discount correctly', function ($price, $discount, $expected) { $calculator = new PriceCalculator(); $result = $calculator->applyDiscount($price, $discount); expect($result)->toBe($expected); })->with([ '10% off $100' => [100, 0.10, 90], '25% off $200' => [200, 0.25, 150], '50% off $50' => [50, 0.50, 25], 'no discount' => [100, 0, 100], ]);

Shared Datasets

<?php // tests/Datasets/Emails.php dataset('emails', [ 'valid gmail' => 'user@gmail.com', 'valid corporate' => 'john.doe@company.com', 'invalid' => 'not-an-email', ]); // Use in any test it('processes email', function ($email) { // Test logic })->with('emails');

Combination Datasets

<?php dataset('users', [ 'admin' => User::factory()->admin()->make(), 'regular' => User::factory()->make(), ]); dataset('actions', ['create', 'edit', 'delete']); it('checks permissions', function ($user, $action) { expect($user->can($action))->toBeBool(); })->with('users', 'actions'); // This runs 6 tests: admin+create, admin+edit, admin+delete, regular+create, etc.

Higher-Order Testing

Pest's higher-order testing allows testing common Laravel patterns without writing boilerplate:

Higher-Order Expectations

<?php // Test that a collection has specific properties it('has users with emails', function () { $users = User::factory()->count(3)->create(); expect($users) ->each->toBeInstanceOf(User::class) ->each->toHaveProperty('email') ->each->email->not->toBeEmpty(); }); // Test sequence of values it('generates sequential IDs', function () { $items = collect([1, 2, 3, 4, 5]); expect($items) ->sequence( fn($item) => $item->toBe(1), fn($item) => $item->toBe(2), fn($item) => $item->toBe(3), fn($item) => $item->toBe(4), fn($item) => $item->toBe(5) ); });

Higher-Order Tests for HTTP

<?php use function Pest\Laravel\{get, post}; it('shows homepage', fn() => get('/') ->assertOk() ->assertSee('Welcome') ); it('creates user', fn() => post('/users', [ 'name' => 'John', 'email' => 'john@example.com' ]) ->assertRedirect() ->assertSessionHas('success') );

Test Setup and Hooks

Global Setup (Pest.php)

<?php // tests/Pest.php uses(Tests\TestCase::class)->in('Feature'); uses(RefreshDatabase::class)->in('Feature'); // Global helper functions function createUser(array $attributes = []): User { return User::factory()->create($attributes); } // Before each test in Feature directory beforeEach(function () { $this->user = createUser(); })->in('Feature'); // After each test afterEach(function () { // Cleanup logic })->in('Feature');

Per-File Setup

<?php // tests/Feature/OrderTest.php uses(RefreshDatabase::class); beforeEach(function () { $this->user = User::factory()->create(); $this->product = Product::factory()->create(); }); it('can create order', function () { // Access $this->user and $this->product $order = Order::create([ 'user_id' => $this->user->id, 'product_id' => $this->product->id ]); expect($order)->toBeInstanceOf(Order::class); });

Pest Plugins

Laravel Plugin

<?php use function Pest\Laravel\{ get, post, put, delete, actingAs, assertDatabaseHas, assertDatabaseMissing }; it('requires authentication', function () { get('/dashboard') ->assertRedirect('/login'); $user = User::factory()->create(); actingAs($user) ->get('/dashboard') ->assertOk(); }); it('creates post', function () { $user = User::factory()->create(); actingAs($user) ->post('/posts', ['title' => 'Test Post']) ->assertCreated(); assertDatabaseHas('posts', ['title' => 'Test Post']); });

Faker Plugin

<?php use function Pest\Faker\{faker}; it('generates fake data', function () { $name = faker()->name; $email = faker()->email; $user = User::create([ 'name' => $name, 'email' => $email ]); expect($user->name)->toBe($name); });

Test Organization

Grouping Tests

<?php describe('UserController', function () { beforeEach(function () { $this->user = User::factory()->create(); }); describe('index', function () { it('lists all users', function () { get('/users')->assertOk(); }); it('paginates results', function () { User::factory()->count(20)->create(); get('/users') ->assertOk() ->assertJsonCount(15, 'data'); }); }); describe('store', function () { it('creates new user', function () { post('/users', ['name' => 'John']) ->assertCreated(); }); it('validates required fields', function () { post('/users', []) ->assertSessionHasErrors(['name', 'email']); }); }); });

Test Tags

<?php it('performs critical operation') ->group('critical', 'slow') ->skip('Not ready yet'); it('runs in CI only') ->skipOnWindows() ->skipOnMac(); it('requires API access') ->skipWhen(!env('API_KEY')) ->todo(); // Mark as incomplete // Run specific groups // ./vendor/bin/pest --group=critical // ./vendor/bin/pest --exclude-group=slow

Practical Example: Testing API

<?php // tests/Feature/Api/ProductApiTest.php use function Pest\Laravel\{get, post, put, delete, actingAs}; uses(RefreshDatabase::class); beforeEach(function () { $this->user = User::factory()->create(); $this->token = $this->user->createToken('test')->plainTextToken; }); describe('Product API', function () { it('lists all products', function () { Product::factory()->count(5)->create(); get('/api/products', [ 'Authorization' => 'Bearer ' . $this->token ]) ->assertOk() ->assertJsonCount(5, 'data') ->assertJsonStructure([ 'data' => [ '*' => ['id', 'name', 'price', 'created_at'] ] ]); }); it('creates product with valid data', function ($name, $price) { post('/api/products', [ 'name' => $name, 'price' => $price ], [ 'Authorization' => 'Bearer ' . $this->token ]) ->assertCreated() ->assertJson([ 'data' => [ 'name' => $name, 'price' => $price ] ]); assertDatabaseHas('products', [ 'name' => $name, 'price' => $price ]); })->with([ ['Laptop', 999.99], ['Mouse', 29.99], ['Keyboard', 79.99], ]); it('validates required fields', function ($field) { post('/api/products', [], [ 'Authorization' => 'Bearer ' . $this->token ]) ->assertStatus(422) ->assertJsonValidationErrors([$field]); })->with(['name', 'price']); it('requires authentication', function () { get('/api/products') ->assertUnauthorized(); }); });
Practice Exercise:

Convert these PHPUnit tests to Pest:

<?php class CartTest extends TestCase { public function test_can_add_item_to_cart() { $cart = new Cart(); $product = new Product(['price' => 100]); $cart->addItem($product); $this->assertCount(1, $cart->items()); $this->assertEquals(100, $cart->total()); } public function test_calculates_total_with_tax() { $cart = new Cart(); $cart->addItem(new Product(['price' => 100])); $total = $cart->totalWithTax(0.15); $this->assertEquals(115, $total); } }

Summary

Pest offers a modern, elegant testing experience with:

  • Minimal boilerplate: Focus on test logic, not setup
  • Expressive syntax: Tests read like documentation
  • Powerful features: Datasets, higher-order testing, plugins
  • Full compatibility: Works alongside existing PHPUnit tests
  • Great DX: Beautiful output and helpful error messages
Getting Started with Pest: You don't have to convert all existing tests. Pest works alongside PHPUnit - start writing new tests with Pest and gradually migrate when convenient.