Testing & TDD
Pest PHP Framework
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.