"Testing leads to failure, and failure leads to understanding."- Burt Rutan
Testing is the backbone of reliable software, but let's be honest-traditional PHPUnit tests can feel verbose and intimidating. Enter Pest, a delightful PHP testing framework that brings simplicity, elegance, and speed to Laravel testing. If you've ever wished your tests could read like plain English while being powerful enough for complex scenarios, Pest is your answer.
Key Takeaways
- Pest offers cleaner syntax than PHPUnit with a functional, expressive API that reads like natural language
- Seamless Laravel integration with built-in support for database testing, HTTP requests, and authentication
- Faster test execution through parallel testing and optimized architecture
- Expectation API makes assertions intuitive with chainable methods like expect($value)->toBe(10)
- Higher-order testing reduces boilerplate code with reusable test patterns
- Plugin ecosystem extends functionality for coverage reports, watch mode, and more
- 100% compatible with PHPUnit – migrate gradually or mix both frameworks in the same project
Table of Contents
- What is Pest and Why Use It?
- Getting Started with Pest in Laravel
- Writing Your First Pest Test
- The Expectation API: Readable Assertions
- Testing Laravel Features with Pest
- Advanced Pest Features
- Parallel Testing for Speed
- Migration from PHPUnit to Pest
- Stats & Practical Insights
- Interesting Facts
- FAQs
- Conclusion
What is Pest and Why Use It?
Pest is a modern PHP testing framework built on top of PHPUnit, created by Nuno Maduro. It focuses on simplicity and developer experience while maintaining all the power of PHPUnit under the hood.
Why Choose Pest Over PHPUnit?
Traditional PHPUnit Test:
<?php
namespace Tests\Unit;
use PHPUnit\Framework\TestCase;
class MathTest extends TestCase
{
public function test_addition_works_correctly()
{
$result = 2 + 2;
$this->assertEquals(4, $result);
}
}
The Same Test in Pest:
<?php
test('addition works correctly', function () {
$result = 2 + 2;
expect($result)->toBe(4);
});
The difference is striking. Pest eliminates class boilerplate, uses natural language for test names, and provides an intuitive expectation API.
Getting Started with Pest in Laravel
Installation
Install Pest via Composer in your Laravel project:
composer require pestphp/pest --dev --with-all-dependencies
composer require pestphp/pest-plugin-laravel --dev
Initialize Pest
Run the initialization command:
php artisan pest:install
This creates a Pest.php configuration file in your tests directory and sets up the necessary structure.
Project Structure
After installation, your test structure looks like:
tests/
├── Pest.php
├── Feature/
│ └── ExampleTest.php
└── Unit/
└── ExampleTest.php
Running Tests
Execute your Pest tests with:
php artisan test
# or
./vendor/bin/pest
For specific tests:
./vendor/bin/pest --filter=user
Writing Your First Pest Test
Let's create a real-world example testing a user service.
Create the Test File
php artisan pest:test UserServiceTest --unit
Write the Test
<?php
use App\Services\UserService;
use App\Models\User;
it('creates a user with valid data', function () {
$service = new UserService();
$userData = [
'name' => 'John Doe',
'email' => 'john@example.com',
'password' => 'password123'
];
$user = $service->create($userData);
expect($user)->toBeInstanceOf(User::class)
->and($user->name)->toBe('John Doe')
->and($user->email)->toBe('john@example.com');
});
it('throws exception for invalid email', function () {
$service = new UserService();
$userData = [
'name' => 'John Doe',
'email' => 'invalid-email',
'password' => 'password123'
];
$service->create($userData);
})->throws(ValidationException::class);
"Programs must be written for people to read, and only incidentally for machines to execute."- Harold Abelson
The Expectation API: Readable Assertions
Pest's expectation API is one of its killer features. Instead of cryptic assertion methods, you get chainable, readable expectations.
Common Expectations
// Value comparisons
expect($value)->toBe(10);
expect($value)->toEqual($expected);
expect($value)->toBeGreaterThan(5);
expect($value)->toBeLessThan(100);
// Type checking
expect($user)->toBeInstanceOf(User::class);
expect($value)->toBeString();
expect($value)->toBeInt();
expect($value)->toBeBool();
expect($value)->toBeArray();
// Null checks
expect($value)->toBeNull();
expect($value)->not->toBeNull();
// Boolean checks
expect($value)->toBeTrue();
expect($value)->toBeFalse();
expect($condition)->toBeTruthy();
// Array expectations
expect($array)->toHaveCount(3);
expect($array)->toContain('apple');
expect($array)->toHaveKey('name');
// String expectations
expect($string)->toStartWith('Hello');
expect($string)->toEndWith('world');
expect($string)->toContain('Laravel');
expect($email)->toMatch('/^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}$/');
Chaining Expectations
expect($user)
->toBeInstanceOf(User::class)
->and($user->email)->toBeString()
->and($user->isActive())->toBeTrue()
->and($user->roles)->toHaveCount(2);
Testing Laravel Features with Pest
Database Testing
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('stores user in database', function () {
$user = User::factory()->create([
'name' => 'Jane Doe'
]);
expect($user->exists)->toBeTrue();
$this->assertDatabaseHas('users', [
'name' => 'Jane Doe'
]);
});
it('deletes user from database', function () {
$user = User::factory()->create();
$userId = $user->id;
$user->delete();
$this->assertDatabaseMissing('users', [
'id' => $userId
]);
});
HTTP Testing
it('returns successful login response', function () {
$user = User::factory()->create([
'password' => bcrypt('password')
]);
$response = $this->postJson('/api/login', [
'email' => $user->email,
'password' => 'password'
]);
$response->assertOk()
->assertJsonStructure([
'token',
'user' => ['id', 'name', 'email']
]);
});
it('validates registration input', function () {
$response = $this->postJson('/api/register', [
'name' => '',
'email' => 'invalid-email'
]);
$response->assertStatus(422)
->assertJsonValidationErrors(['name', 'email']);
});
Authentication Testing
use App\Models\User;
it('requires authentication for dashboard', function () {
$response = $this->get('/dashboard');
$response->assertRedirect('/login');
});
it('allows authenticated users to view dashboard', function () {
$user = User::factory()->create();
$response = $this->actingAs($user)->get('/dashboard');
$response->assertOk()
->assertSee('Welcome, ' . $user->name);
});
Testing Jobs and Events
use App\Jobs\SendWelcomeEmail;
use App\Events\UserRegistered;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\Event;
it('dispatches welcome email job', function () {
Queue::fake();
$user = User::factory()->create();
SendWelcomeEmail::dispatch($user);
Queue::assertPushed(SendWelcomeEmail::class, function ($job) use ($user) {
return $job->user->id === $user->id;
});
});
it('fires user registered event', function () {
Event::fake();
$user = User::factory()->create();
Event::assertDispatched(UserRegistered::class, function ($event) use ($user) {
return $event->user->id === $user->id;
});
});
Advanced Pest Features
Higher-Order Tests
Reduce duplication with higher-order test methods:
it('has correct user properties')
->expect(fn() => User::factory()->create())
->toBeInstanceOf(User::class)
->toHaveProperty('email')
->toHaveProperty('name');
Custom Expectations
Create reusable custom expectations:
// tests/Pest.php
expect()->extend('toBeValidEmail', function () {
return $this->toMatch('/^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}$/');
});
// In your test
expect('user@example.com')->toBeValidEmail();
Datasets
Test multiple scenarios with datasets:
it('validates email formats', function ($email, $isValid) {
$validator = Validator::make(
['email' => $email],
['email' => 'email']
);
expect($validator->passes())->toBe($isValid);
})->with([
['valid@example.com', true],
['invalid-email', false],
['user@domain', false],
['user@domain.com', true],
]);
Shared Setup with beforeEach
beforeEach(function () {
$this->user = User::factory()->create();
$this->actingAs($this->user);
});
it('allows user to update profile', function () {
$response = $this->patch('/profile', [
'name' => 'Updated Name'
]);
$response->assertOk();
expect($this->user->fresh()->name)->toBe('Updated Name');
});
Test Filtering with Groups
it('processes payment', function () {
// test code
})->group('payments', 'integration');
// Run only payment tests
// ./vendor/bin/pest --group=payments
Skip and Todo
it('handles complex scenario')->skip('Not implemented yet');
it('tests new feature')->todo();
Parallel Testing for Speed
Pest supports parallel test execution for dramatic speed improvements.
Enable Parallel Testing
./vendor/bin/pest --parallel
Configure in Pest.php
// tests/Pest.php
uses(TestCase::class)->in('Feature', 'Unit');
// Enable parallel execution
pest()->parallel();
Performance Comparison
# Sequential
./vendor/bin/pest
# Time: 45.32 seconds
# Parallel
./vendor/bin/pest --parallel
# Time: 12.18 seconds (73% faster)
Migration from PHPUnit to Pest
Gradual Migration Strategy
You don't need to migrate everything at once. Pest and PHPUnit coexist perfectly:
- Install Pest alongside existing PHPUnit tests
- Write new tests in Pest while keeping old ones
- Migrate selectively when refactoring existing features
- Run both with php artisan test
- Conversion Example
Before (PHPUnit):
<?php
namespace Tests\Unit;
use PHPUnit\Framework\TestCase;
use App\Services\Calculator;
class CalculatorTest extends TestCase
{
private Calculator $calculator;
protected function setUp(): void
{
parent::setUp();
$this->calculator = new Calculator();
}
public function test_it_adds_numbers()
{
$result = $this->calculator->add(2, 3);
$this->assertEquals(5, $result);
}
public function test_it_throws_exception_for_division_by_zero()
{
$this->expectException(\DivisionByZeroError::class);
$this->calculator->divide(10, 0);
}
}
After (Pest):
<?php
use App\Services\Calculator;
beforeEach(function () {
$this->calculator = new Calculator();
});
it('adds numbers', function () {
$result = $this->calculator->add(2, 3);
expect($result)->toBe(5);
});
it('throws exception for division by zero', function () {
$this->calculator->divide(10, 0);
})->throws(DivisionByZeroError::class);
Stats & Practical Insights
Performance Metrics
- Code reduction: Pest tests average 30-40% fewer lines than equivalent PHPUnit tests
- Parallel execution: Can reduce test suite time by 60-80% on multi-core systems
- Adoption rate: Over 50,000+ projects on GitHub use Pest (as of 2024)
- Laravel community: Pest is the recommended testing framework by many Laravel developers
Real-World Use Cases
E-commerce Platform Testing
// Product inventory management
it('decrements stock after purchase', function () {
$product = Product::factory()->create(['stock' => 10]);
$order = Order::create([
'product_id' => $product->id,
'quantity' => 3
]);
expect($product->fresh()->stock)->toBe(7);
});
// Discount calculation
it('applies percentage discount correctly', function () {
$cart = new ShoppingCart();
$cart->addItem(100);
$cart->applyDiscount(20); // 20%
expect($cart->getTotal())->toBe(80.0);
});
API Rate Limiting
it('blocks requests after rate limit', function () {
$user = User::factory()->create();
// Make 60 requests (assuming limit is 60/minute)
for ($i = 0; $i < 60; $i++) {
$this->actingAs($user)->get('/api/endpoint');
}
$response = $this->actingAs($user)->get('/api/endpoint');
$response->assertStatus(429); // Too Many Requests
});
Email Notification Testing
use Illuminate\Support\Facades\Mail;
use App\Mail\OrderConfirmation;
it('sends order confirmation email', function () {
Mail::fake();
$order = Order::factory()->create();
$order->sendConfirmation();
Mail::assertSent(OrderConfirmation::class, function ($mail) use ($order) {
return $mail->order->id === $order->id;
});
});
Interesting Facts
- Creator: Pest was created by Nuno Maduro, a Laravel core team member and creator of Laravel Zero
- Philosophy: Inspired by Jest (JavaScript) with a focus on simplicity and developer happiness
- Compatibility: 100% compatible with PHPUnit - uses PHPUnit internally
- Plugins: Over 20 official and community plugins available for extended functionality
- Zero Configuration: Works out-of-the-box with sensible defaults
- Type Coverage: Pest 3.x includes built-in type coverage analysis to ensure your tests use proper types
- Architecture Testing: Pest includes Arch testing to enforce architectural rules (e.g., "controllers should not access models directly")
- Community Growth: Pest has grown to over 9,000 GitHub stars since its launch in 2020
- Laravel Integration: Officially recognized and recommended by Laravel documentation
- Watch Mode: Pest can automatically rerun tests when files change with --watch flag
"Quality is not an act, it is a habit."- Aristotle
FAQs
1. Is Pest faster than PHPUnit?
Yes, Pest is generally faster, especially with parallel testing enabled. The framework is optimized for performance, and parallel execution can reduce test suite time by 60-80%. The functional syntax also reduces overhead from class instantiation.
2. Can I use Pest and PHPUnit together in the same project?
Absolutely! Pest is built on top of PHPUnit, so they coexist perfectly. You can have some tests in PHPUnit and others in Pest. Both will run when you execute php artisan test. This makes migration easy and gradual.
3. Does Pest support code coverage reports?
Yes. Run Pest with the coverage flag:
./vendor/bin/pest --coverage
For detailed HTML reports:
./vendor/bin/pest --coverage --coverage-html=coverage-report
4. How do I debug failing Pest tests?
Use the same debugging techniques as PHPUnit:
Add dd() or dump() in your test
Use --filter to run specific tests: ./vendor/bin/pest --filter=user
Use --bail to stop on first failure: ./vendor/bin/pest --bail
Enable verbose mode: ./vendor/bin/pest -v
5. Can I use Pest for non-Laravel PHP projects?
Yes! While Pest has excellent Laravel integration, it works with any PHP project. Just install the base Pest package without the Laravel plugin:
composer require pestphp/pest --dev
6. What's the difference between test() and it()?
They're functionally identical - just syntactic sugar for readability:
test('user can login', function () { ... });
it('allows user to login', function () { ... });
Choose whichever reads better for your test description.
7. How do I test private or protected methods with Pest?
Generally, you shouldn't test private methods directly - test public behavior instead. If absolutely necessary, use reflection:
$reflection = new ReflectionClass($object);
$method = $reflection->getMethod('privateMethod');
$method->setAccessible(true);
$result = $method->invoke($object);
8. Does Pest support snapshot testing?
Yes, with the pestphp/pest-plugin-snapshots plugin:
composer require pestphp/pest-plugin-snapshots --dev
Then use:
expect($data)->toMatchSnapshot();
9. How do I run only unit tests or feature tests?
Use directory filtering:
./vendor/bin/pest tests/Unit
./vendor/bin/pest tests/Feature
10. Can Pest run tests in random order?
Yes, use the --order-random flag:
./vendor/bin/pest --order-random
This helps identify tests with hidden dependencies on execution order.
Conclusion
Pest transforms Laravel testing from a chore into a delightful experience. Its clean, expressive syntax reduces boilerplate while maintaining all the power of PHPUnit underneath. Whether you're testing APIs, database interactions, or complex business logic, Pest makes your tests more readable, maintainable, and faster to execute.
Top comments (0)