Pest is a testing framework built on top of PHPUnit and is the default test runner for new Laravel 11+ projects. It lets you write tests as closures with concise, expressive syntax while still having full access to PHPUnit’s assertion library.
Aspect
Pest
PHPUnit
Test definition
test() / it() closures
Methods on a class
Assertions
Expectation API (expect()->toBe())
$this->assert*()
Datasets
dataset() / with()
@dataProvider
Hooks
beforeEach() / afterEach()
setUp() / tearDown()
Grouping
describe()
Test classes
Pest tests run on PHPUnit under the hood, so they can coexist with existing PHPUnit test classes. Run them with php artisan test or vendor/bin/pest.
The simplest form. The first argument becomes the test description.
test('a user can log in with their email address', function () { $user = User::factory()->create(); $response = $this->post('/login', [ 'email' => $user->email, 'password' => 'password', ]); $response->assertRedirect('/dashboard');});
Groups related tests and allows sharing beforeEach() state within the group.
describe('order management', function () { beforeEach(function () { $this->user = User::factory()->create(); $this->actingAs($this->user); }); it('returns the list of orders for the current user', function () { Order::factory(3)->for($this->user)->create(); $response = $this->get('/orders'); $response->assertOk()->assertJsonCount(3, 'data'); }); it('does not return orders belonging to another user', function () { $other = User::factory()->create(); Order::factory()->for($other)->create(); $response = $this->get('/orders'); $response->assertOk()->assertJsonCount(0, 'data'); });});
describe() blocks can be nested, but keep the depth to two levels to keep tests readable.
expect($user)->toBeInstanceOf(User::class);expect($user->email)->toMatchRegex('/^.+@.+\..+$/');// Confirm a record was persistedexpect(User::where('email', '[email protected]')->exists())->toBeTrue();
use App\Services\PaymentGateway;use Mockery\MockInterface;test('the payment gateway is called with the correct amount', function () { $this->mock(PaymentGateway::class, function (MockInterface $mock) { $mock->expects('charge') ->with(1000, 'usd') ->andReturn(['status' => 'succeeded']); }); $result = app(PaymentGateway::class)->charge(1000, 'usd'); expect($result['status'])->toBe('succeeded');});
Spies call the real implementation and record what was called.
use App\Services\NotificationService;test('the notification service is called after an order is placed', function () { $spy = $this->spy(NotificationService::class); $this->post('/orders', ['amount' => 1000]); $spy->shouldHaveReceived('send')->once()->with('order.created');});
test('it throws an exception when the API returns 503', function () { Http::fake([ 'api.example.com/*' => Http::response([], 503), ]); expect(fn () => app(UserApiClient::class)->find(1)) ->toThrow(\App\Exceptions\ApiUnavailableException::class);});
use Illuminate\Http\Client\ConnectionException;test('it returns null when the connection fails', function () { Http::fake(fn () => throw new ConnectionException('Connection refused')); $result = app(UserApiClient::class)->findWithFallback(1); expect($result)->toBeNull();});
use Illuminate\Support\Facades\Event;use App\Events\OrderCreated;test('placing an order dispatches the OrderCreated event', function () { Event::fake(); $this->post('/orders', ['product_id' => 1, 'amount' => 1000]); Event::assertDispatched(OrderCreated::class, function ($event) { return $event->order->amount === 1000; });});
Fake only specific events and let others propagate normally:
use Illuminate\Support\Facades\Notification;use App\Notifications\OrderShipped;test('shipping an order notifies the customer', function () { Notification::fake(); $user = User::factory()->create(); $order = Order::factory()->for($user)->create(); $this->post("/orders/{$order->id}/ship"); Notification::assertSentTo($user, OrderShipped::class, function ($notification) use ($order) { return $notification->order->id === $order->id; });});
test('the report command prompts for confirmation before running', function () { $this->artisan('reports:generate') ->expectsQuestion('Are you sure?', 'yes') ->expectsOutput('Report generated.') ->assertExitCode(0);});
use Illuminate\Foundation\Testing\RefreshDatabase;test('it creates a user', function () { $user = User::factory()->create(['name' => 'Laravel']); expect(User::count())->toBe(1); expect($user->name)->toBe('Laravel');});
For most projects, RefreshDatabase is the safer choice. Consider LazilyRefreshDatabase when you have a large number of tests that do not interact with the database.
Pest’s architecture testing API lets you enforce structural rules across your codebase. These run as part of your normal test suite.
// tests/Architecture/AppTest.php// Controllers must not depend on repositories directlyarch('controllers do not use repositories directly') ->expect('App\Http\Controllers') ->not->toUse('App\Repositories');// All commands must be in the Console namespacearch('commands are in the Console namespace') ->expect('App\Console\Commands') ->toExtend('Illuminate\Console\Command');// Models must not call external HTTP servicesarch('models do not make HTTP calls') ->expect('App\Models') ->not->toUse('Illuminate\Support\Facades\Http');
# Print coverage summary to the terminalphp artisan test --coverage# Fail CI if coverage drops below 80%php artisan test --coverage --min=80# Generate an HTML reportvendor/bin/pest --coverage-html=coverage/# Profile slow testsphp artisan test --profile
Coverage measurement significantly increases test run time. In CI, run coverage in a dedicated job or only on pull requests to avoid slowing down your main pipeline.