Pest is a modern, elegant, and expressive testing framework for PHP applications, and it integrates seamlessly with Laravel. Built as a layer on top of PHPUnit, Pest provides a cleaner, more enjoyable syntax while still maintaining full compatibility with PHPUnit’s powerful testing engine. Because of its simplicity, speed, and expressive test definitions, Pest has rapidly become the preferred testing tool for many Laravel developers.
Laravel now ships with Pest scaffolding out of the box, which means that you can start writing Pest tests without any manual configuration. Whether you are writing unit tests, feature tests, API tests, or mocking external dependencies, Pest gives you a syntax that feels natural and productive. This detailed 3000-word post explores Pest in depth, including installation, structure, test syntax, dataset functionality, plugins, mocking, expectations, architecture, comparisons with PHPUnit, real-world examples, and best-practice testing strategies.
What Makes Pest Different From PHPUnit
Although Pest is powered by PHPUnit under the hood, it replaces PHPUnit’s verbose class-based testing syntax with a much more expressive, closure-based approach. Pest focuses on minimalism, readability, and developer productivity.
The key differences include:
- Closure-based test definitions instead of classes
- Cleaner syntax for expectations
- Faster execution with parallel support
- Plugins to extend functionality
- No need for boilerplate code
- Built-in support for datasets
- Snapshot testing
- Descriptive exception testing
- Beautiful output formatting
Pest allows developers to write tests that read more like natural English, reducing mental fatigue and making test suites more accessible.
Installing Pest in a Laravel Project
Laravel now includes Pest scaffolding by default, but if you need to install Pest manually in a fresh project, you can run:
composer require pestphp/pest --dev
php artisan pest:install
This command replaces PHPUnit’s default test structure with Pest-friendly scaffolding, including:
tests/Pest.phpconfiguration file- example tests
- converted directory structure
After installation, you can immediately begin writing expressive Pest tests.
Basic Pest Test Example
A simple test in Pest looks like:
it('adds numbers', function () {
expect(2 + 2)->toBe(4);
});
This single line replaces an entire PHPUnit class like:
public function test_adds_numbers()
{
$this->assertEquals(4, 2 + 2);
}
The difference in readability is dramatic.
Pest Test File Structure
Pest stores tests in the standard Laravel test directories:
tests/
Feature/
Unit/
Pest.php
The Pest.php file acts like a global bootstrap where you can:
- load helpers
- define shared functions
- apply global hooks
- configure test defaults
Writing Unit Tests in Pest
Unit tests validate small, isolated pieces of logic.
Example:
it('calculates tax correctly', function () {
$result = calculateTax(100);
expect($result)->toBe(15);
});
Unit tests in Pest are clean and expressive because they avoid unnecessary class structure.
Writing Feature Tests in Pest
Feature tests simulate real HTTP interactions and application workflows.
Example:
it('loads the home page', function () {
$response = $this->get('/');
$response->assertStatus(200);
});
Pest integrates with Laravel’s HTTP test methods seamlessly.
Using expect() Assertions
Pest replaces PHPUnit’s assertion methods with a fluent syntax:
expect($value)->toBe($expected);
expect($value)->not->toBe($expected);
expect($number)->toBeGreaterThan(10);
expect($string)->toContain('Laravel');
expect($object)->toBeInstanceOf(User::class);
Expectations offer dozens of expressive matchers.
Describing a Group of Tests
You can group related tests using describe():
describe('Math operations', function () {
it('adds numbers', function () {
expect(2 + 2)->toBe(4);
});
it('multiplies numbers', function () {
expect(3 * 3)->toBe(9);
});
});
This creates a readable grouping of related functionality.
Using Before and After Hooks
Hooks help you run setup or teardown logic.
beforeEach(function () {
$this->calculator = new Calculator;
});
afterEach(function () {
// cleanup if necessary
});
This is similar to PHPUnit’s setUp() and tearDown() but cleaner.
Using Test Datasets
Datasets allow you to run the same test with multiple input values.
dataset('numbers', [
[2, 2, 4],
[3, 5, 8],
[10, 15, 25]
]);
it('adds numbers correctly', function ($a, $b, $result) {
expect($a + $b)->toBe($result);
})->with('numbers');
Datasets eliminate repetitive tests.
Inline Datasets
You can also define datasets inline:
it('multiplies numbers', function ($a, $b, $result) {
expect($a * $b)->toBe($result);
})->with([
[2, 2, 4],
[3, 4, 12],
]);
Using Test Helpers
You can define custom helper functions inside Pest.php:
function createUser()
{
return User::factory()->create();
}
Use it in any test:
it('checks user email', function () {
$user = createUser();
expect($user->email)->toBeString();
});
Grouping Tests Into Test Cases
You can extend Laravel’s base test case:
uses(Tests\TestCase::class)->in('Feature');
This allows all Feature tests to share base functionality such as database migrations.
Pest Expectations Overview
Common expectations include:
toBe()
toEqual()
toMatchArray()
toBeTrue()
toBeFalse()
toBeNull()
toBeJson()
toBeString()
toStartWith()
toEndWith()
toContain()
toHaveCount()
toHaveKey()
toThrow()
These expectations make tests highly expressive.
Testing Exceptions in Pest
Pest makes exception testing simple:
it('throws an exception', function () {
expect(fn() => doSomethingDangerous())
->toThrow(Exception::class);
});
You no longer need PHPUnit’s expectException() method.
Snapshot Testing
Snapshot testing compares a value to a stored reference snapshot.
expect($data)->toMatchSnapshot();
Snapshots are great when testing:
- API responses
- complex arrays
- rendered views
- formatted text
Parallel Testing in Pest
Pest integrates with Laravel’s built-in parallel testing:
php artisan test --parallel
This speeds up test execution significantly.
Using Laravel’s Test Helpers in Pest
You can still use Laravel’s helpers:
$this->get('/')->assertStatus(200);
$this->post('/login', [...])->assertRedirect('/dashboard');
Pest does not limit functionality; it only improves syntax.
Mocking Dependencies in Pest
You can still mock using Laravel or Mockery:
$service = Mockery::mock(PaymentService::class);
$service->shouldReceive('charge')->andReturn(true);
app()->instance(PaymentService::class, $service);
it('processes payments', function () {
// test logic
});
Spying With Mockery
$spy = Mockery::spy(UserService::class);
$spy->shouldHaveReceived('create');
Spies help verify method calls.
Pest Plugins
Pest supports plugins that extend functionality.
Popular plugins include:
- Pest Laravel plugin
- Pest Parallel plugin
- Pest Coverage plugin
- Pest Snapshot plugin
Plugins allow you to build powerful testing workflows.
Running Tests With Pest
Execute all tests:
php artisan test
Or use Pest directly:
./vendor/bin/pest
Filter tests by name:
pest --filter=login
Run only failed tests:
pest --rerun
Migrating From PHPUnit to Pest
Laravel’s pest:install command automatically converts PHPUnit tests to Pest format. You can also keep PHPUnit tests; Pest works with both.
Migration involves:
- removing class structure
- replacing assertions with expect()
- replacing setUp() with beforeEach()
- grouping tests with describe()
Differences Between Pest and PHPUnit Syntax
PHPUnit example:
public function test_user_has_name()
{
$user = new User(['name' => 'John']);
$this->assertEquals('John', $user->name);
}
Pest equivalent:
it('checks user name', function () {
$user = new User(['name' => 'John']);
expect($user->name)->toBe('John');
});
Pest provides a cleaner, more natural style.
Writing API Tests in Pest
Example:
it('returns product list', function () {
$response = $this->getJson('/api/products');
$response->assertStatus(200)
->assertJsonStructure([
'*' => ['id', 'name', 'price']
]);
});
REST API testing becomes simple and readable.
Writing Authentication Tests
it('logs in a user', function () {
$user = User::factory()->create([
'password' => bcrypt('password'),
]);
$this->post('/login', [
'email' => $user->email,
'password' => 'password',
])->assertRedirect('/dashboard');
});
Testing Validation Rules in Pest
it('validates missing fields', function () {
$response = $this->post('/register', []);
$response->assertSessionHasErrors(['name', 'email']);
});
Writing Database Tests
use RefreshDatabase;
it('stores a new post', function () {
$this->post('/posts', [
'title' => 'New Post',
'content' => 'Lorem ipsum'
]);
$this->assertDatabaseHas('posts', ['title' => 'New Post']);
});
Snapshot Testing for API Responses
$response = $this->getJson('/api/products');
expect($response->json())->toMatchSnapshot();
Snapshots ensure consistent formatting over time.
Code Coverage With Pest
Enable coverage:
pest --coverage
Coverage reports help identify untested code.
Using Higher Order Expectations
Pest allows chaining:
expect($user)
->name->toBe('John')
->email->toBeString();
This makes complex assertions readable.
Using the expect() Helper Globally
You can test multiple things in one chain:
expect($data)
->toBeArray()
->toHaveCount(3)
->toContain('Laravel');
Testing Events
Event::fake();
it('fires an event', function () {
event(new UserRegistered);
Event::assertDispatched(UserRegistered::class);
});
Testing Jobs
Bus::fake();
it('dispatches job', function () {
dispatch(new SendEmailJob);
Bus::assertDispatched(SendEmailJob::class);
});
Testing Notifications
Notification::fake();
it('sends email', function () {
Notification::assertSentTo($user, WelcomeNotification::class);
});
Using Factories in Pest Tests
Factories integrate perfectly:
$post = Post::factory()->create();
expect($post->title)->toBeString();
Testing Middleware
it('redirects unauthorized users', function () {
$this->get('/admin')->assertRedirect('/login');
});
Testing Routes
it('checks route exists', function () {
$this->get('/')->assertOk();
});
Structuring Pest Tests for Large Projects
Use directories such as:
tests/Feature/Auth
tests/Feature/API
tests/Unit/Services
tests/Unit/Helpers
Group related functionality with describe().
Using Higher Order Tests
You can reference variables in the test context:
beforeEach(function () {
$this->user = User::factory()->create();
});
it('checks user email')
->expect(fn() => $this->user->email)
->toBeString();
Pest’s Philosophy: Minimalism and Expression
Pest was created with several goals:
- Make tests easier to write
- Make tests enjoyable to read
- Remove unnecessary boilerplate
- Encourage developers to write more tests
- Improve project maintainability
Pest achieves these goals without sacrificing PHPUnit compatibility.
Best Practices for Writing Pest Tests
- Name tests descriptively
- Keep tests short
- Use datasets for repetitive input
- Use describe() to group related tests
- Use beforeEach() for setup logic
- Use factories for generating test data
- Mock external services
- Test behavior, not implementation
- Avoid large monolithic tests
- Run tests frequently
Real-World Example: Testing a Registration Flow
describe('User Registration', function () {
it('shows the registration page', function () {
$this->get('/register')->assertOk();
});
it('creates a new user', function () {
$response = $this->post('/register', [
'name' => 'John',
'email' => '[email protected]',
'password' => 'secret123',
'password_confirmation' => 'secret123',
]);
$response->assertRedirect('/home');
$this->assertDatabaseHas('users', [
'email' => '[email protected]'
]);
});
});
Real-World Example: Testing an API Endpoint
it('returns a list of products', function () {
Product::factory()->count(3)->create();
$response = $this->getJson('/api/products');
$response->assertStatus(200)
->assertJsonCount(3);
});
Comparing Pest and PHPUnit
Pest is:
- Faster to write
- Easier to read
- More expressive
- Less verbose
- More modern
- Easier to expand with plugins
PHPUnit is:
- More traditional
- Class-based
- Verbose
- Still fully supported
Leave a Reply