DEV Community

Cover image for Laravel Testing Made Simple with Pest: Write Clean, Readable, and Fast Tests
Vatsal Acharya
Vatsal Acharya

Posted on

Laravel Testing Made Simple with Pest: Write Clean, Readable, and Fast Tests

"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

  1. What is Pest and Why Use It?
  2. Getting Started with Pest in Laravel
  3. Writing Your First Pest Test
  4. The Expectation API: Readable Assertions
  5. Testing Laravel Features with Pest
  6. Advanced Pest Features
  7. Parallel Testing for Speed
  8. Migration from PHPUnit to Pest
  9. Stats & Practical Insights
  10. Interesting Facts
  11. FAQs
  12. 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);
    }
}
Enter fullscreen mode Exit fullscreen mode

The Same Test in Pest:

<?php

test('addition works correctly', function () {
    $result = 2 + 2;
    expect($result)->toBe(4);
});
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Initialize Pest
Run the initialization command:

php artisan pest:install

Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Running Tests

Execute your Pest tests with:

php artisan test
# or
./vendor/bin/pest
Enter fullscreen mode Exit fullscreen mode

For specific tests:

./vendor/bin/pest --filter=user

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

"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,}$/');

Enter fullscreen mode Exit fullscreen mode

Chaining Expectations

expect($user)
    ->toBeInstanceOf(User::class)
    ->and($user->email)->toBeString()
    ->and($user->isActive())->toBeTrue()
    ->and($user->roles)->toHaveCount(2);
Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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']);
});

Enter fullscreen mode Exit fullscreen mode

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);
});
Enter fullscreen mode Exit fullscreen mode

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;
    });
});

Enter fullscreen mode Exit fullscreen mode

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');
Enter fullscreen mode Exit fullscreen mode

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();

Enter fullscreen mode Exit fullscreen mode

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],
]);
Enter fullscreen mode Exit fullscreen mode

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');
});
Enter fullscreen mode Exit fullscreen mode

Test Filtering with Groups

it('processes payment', function () {
    // test code
})->group('payments', 'integration');

// Run only payment tests
// ./vendor/bin/pest --group=payments
Enter fullscreen mode Exit fullscreen mode

Skip and Todo

it('handles complex scenario')->skip('Not implemented yet');

it('tests new feature')->todo();

Enter fullscreen mode Exit fullscreen mode

Parallel Testing for Speed

Pest supports parallel test execution for dramatic speed improvements.

Enable Parallel Testing

./vendor/bin/pest --parallel

Enter fullscreen mode Exit fullscreen mode

Configure in Pest.php

// tests/Pest.php
uses(TestCase::class)->in('Feature', 'Unit');

// Enable parallel execution
pest()->parallel();
Enter fullscreen mode Exit fullscreen mode

Performance Comparison

# Sequential
./vendor/bin/pest
# Time: 45.32 seconds

# Parallel
./vendor/bin/pest --parallel
# Time: 12.18 seconds (73% faster)
Enter fullscreen mode Exit fullscreen mode

Migration from PHPUnit to Pest

Gradual Migration Strategy

You don't need to migrate everything at once. Pest and PHPUnit coexist perfectly:

  1. Install Pest alongside existing PHPUnit tests
  2. Write new tests in Pest while keeping old ones
  3. Migrate selectively when refactoring existing features
  4. Run both with php artisan test
  5. 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);
    }
}

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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);
});

Enter fullscreen mode Exit fullscreen mode

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
});
Enter fullscreen mode Exit fullscreen mode

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;
    });
});
Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

For detailed HTML reports:

./vendor/bin/pest --coverage --coverage-html=coverage-report

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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 () { ... });
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

8. Does Pest support snapshot testing?

Yes, with the pestphp/pest-plugin-snapshots plugin:

composer require pestphp/pest-plugin-snapshots --dev

Enter fullscreen mode Exit fullscreen mode

Then use:

expect($data)->toMatchSnapshot();

Enter fullscreen mode Exit fullscreen mode

9. How do I run only unit tests or feature tests?

Use directory filtering:

./vendor/bin/pest tests/Unit

Enter fullscreen mode Exit fullscreen mode
./vendor/bin/pest tests/Feature

Enter fullscreen mode Exit fullscreen mode

10. Can Pest run tests in random order?

Yes, use the --order-random flag:

./vendor/bin/pest --order-random

Enter fullscreen mode Exit fullscreen mode

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)