Laravel14 min read
Testing with Pest 4
A test proves your code does what the ticket asks. A PR without tests doesn't get reviewed here: this is how to write good ones, fast.
Which tests to write
In a Laravel API, Feature tests give the most confidence per effort: they go through the route, validation, Action and database. Unit tests cover pure logic, architecture tests set the guardrails.
Width = share of tests in the suite
Feature
A real HTTP request against the app, with an in-memory SQLite database. Most of the suite.
- Time per test
- ~80 ms
- Typical volume
- 200+
- Folder
- tests/Feature
Install and configure
Laravel 13 ships Pest by default. Shared setup lives in tests/Pest.php: the RefreshDatabase trait and shared helpers.
# New project: Pest is the default test runner
laravel new invoicing --pest
# Existing project
composer require pestphp/pest pestphp/pest-plugin-laravel --dev --with-all-dependencies
./vendor/bin/pest --init
# Browser testing (Pest 4, Playwright under the hood)
composer require pestphp/pest-plugin-browser --dev
npm install playwright@latest && npx playwright install<?php
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
pest()->extend(TestCase::class)
->use(RefreshDatabase::class)
->in('Feature', 'Browser');
/** Valid payload shared by every invoice test. Override only what the test is about. */
function invoicePayload(array $overrides = []): array
{
return array_replace_recursive([
'customer_id' => \App\Models\Customer::factory()->create()->id,
'due_at' => now()->addMonth()->toDateString(),
'lines' => [
['label' => 'Audit UX', 'quantity' => 2, 'unit_price' => 45_000],
],
], $overrides);
}
function signIn(?User $user = null): User
{
$user ??= User::factory()->create();
test()->actingAs($user);
return $user;
}<!-- phpunit.xml: tests run on an in-memory SQLite database -->
<php>
<env name="APP_ENV" value="testing"/>
<env name="DB_CONNECTION" value="sqlite"/>
<env name="DB_DATABASE" value=":memory:"/>
<env name="CACHE_STORE" value="array"/>
<env name="QUEUE_CONNECTION" value="sync"/>
<env name="MAIL_MAILER" value="array"/>
<env name="BCRYPT_ROUNDS" value="4"/>
</php>Red, green, refactor
For a bug, always start with a red test that reproduces it. For a feature, write at least the happy-path test before the code.
Write the test for the expected behavior and run it: it must fail, for the right reason.
$ pest --filter="rejects overdue"
Unit test: pure logic
No database, no HTTP. Datasets (->with()) cover several cases in one readable test.
<?php
use App\Data\InvoiceData;
use App\Enums\InvoiceStatus;
it('computes the total in cents', function () {
// Arrange
$data = InvoiceData::fromArray([
'customer_id' => 1,
'due_at' => '2026-10-01',
'lines' => [
['label' => 'Design', 'quantity' => 2, 'unit_price' => 10_000],
['label' => 'Dev', 'quantity' => 1, 'unit_price' => 25_050],
],
]);
// Act + Assert
expect($data->total())->toBe(45_050);
});
it('only allows forward status transitions', function (InvoiceStatus $from, InvoiceStatus $to, bool $allowed) {
expect($from->canTransitionTo($to))->toBe($allowed);
})->with([
'draft to sent' => [InvoiceStatus::Draft, InvoiceStatus::Sent, true],
'sent to paid' => [InvoiceStatus::Sent, InvoiceStatus::Paid, true],
'draft to paid' => [InvoiceStatus::Draft, InvoiceStatus::Paid, false],
'paid to draft' => [InvoiceStatus::Paid, InvoiceStatus::Draft, false],
]);Feature test: the whole endpoint
For each endpoint, cover at least: the happy path, validation, authentication and authorization. Group them with describe().
<?php
use App\Enums\InvoiceStatus;
use App\Models\Invoice;
use App\Models\User;
describe('POST /api/v1/invoices', function () {
it('creates a draft invoice', function () {
$user = signIn();
$this->postJson('/api/v1/invoices', invoicePayload())
->assertCreated()
->assertJsonPath('data.status', InvoiceStatus::Draft->value)
->assertJsonPath('data.total', 90_000);
$this->assertDatabaseHas('invoices', [
'author_id' => $user->id,
'total' => 90_000,
]);
});
it('rejects invalid payloads', function (array $overrides, string $field) {
signIn();
$this->postJson('/api/v1/invoices', invoicePayload($overrides))
->assertUnprocessable()
->assertJsonValidationErrors($field);
})->with([
'past due date' => [['due_at' => '2020-01-01'], 'due_at'],
'no lines' => [['lines' => []], 'lines'],
'zero quantity' => [['lines' => [['quantity' => 0]]], 'lines.0.quantity'],
]);
it('requires authentication', function () {
$this->postJson('/api/v1/invoices', invoicePayload())->assertUnauthorized();
});
it('forbids users without permission', function () {
signIn(User::factory()->readOnly()->create());
$this->postJson('/api/v1/invoices', invoicePayload())->assertForbidden();
expect(Invoice::count())->toBe(0);
});
});Testing an Action directly
When the Action is reused outside HTTP (command, job), test it directly. Side effects are isolated with fakes.
<?php
use App\Actions\Invoice\CreateInvoice;
use App\Data\InvoiceData;
use App\Events\InvoiceCreated;
use App\Models\User;
use Illuminate\Support\Facades\Event;
it('dispatches InvoiceCreated once the invoice is stored', function () {
Event::fake([InvoiceCreated::class]);
$author = User::factory()->create();
$invoice = app(CreateInvoice::class)->handle(
$author,
InvoiceData::fromArray(invoicePayload()),
);
expect($invoice)
->lines->toHaveCount(1)
->author_id->toBe($author->id);
Event::assertDispatched(
InvoiceCreated::class,
fn (InvoiceCreated $event) => $event->invoice->is($invoice),
);
});Factories and states
Never rely on seeders or existing data. Each test creates what it needs with a factory, and states name business situations.
<?php
namespace Database\Factories;
use App\Enums\InvoiceStatus;
use App\Models\Customer;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
/** @extends Factory<\App\Models\Invoice> */
class InvoiceFactory extends Factory
{
public function definition(): array
{
return [
'customer_id' => Customer::factory(),
'author_id' => User::factory(),
'status' => InvoiceStatus::Draft,
'total' => fake()->numberBetween(10_000, 500_000),
'due_at' => now()->addMonth(),
];
}
public function sent(): static
{
return $this->state(['status' => InvoiceStatus::Sent]);
}
public function overdue(): static
{
return $this->sent()->state(['due_at' => now()->subWeek()]);
}
}Isolating the outside world
A test must never send a real email, call a real API or wait a real day.
| Need | Laravel tool |
|---|---|
| Check an event is dispatched | Event::fake() |
| Check a job is queued | Queue::fake() |
| Check an email is sent | Mail::fake() |
| Simulate an external API | Http::fake() |
| Write files without a real disk | Storage::fake('s3') |
| Move forward in time | $this->travel(5)->days() |
<?php
use App\Jobs\SendInvoiceReminder;
use App\Mail\InvoiceOverdue;
use App\Models\Invoice;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Queue;
it('queues a reminder for overdue invoices', function () {
Queue::fake();
$invoice = Invoice::factory()->sent()->create(['due_at' => now()->subDay()]);
$this->artisan('invoices:remind')->assertSuccessful();
Queue::assertPushed(SendInvoiceReminder::class, fn ($job) => $job->invoice->is($invoice));
});
it('mails the customer 30 days after the due date', function () {
Mail::fake();
$invoice = Invoice::factory()->sent()->create(['due_at' => now()]);
$this->travel(30)->days();
(new SendInvoiceReminder($invoice))->handle();
Mail::assertQueued(InvoiceOverdue::class);
});
it('marks the invoice paid when the payment provider confirms', function () {
Http::preventStrayRequests();
Http::fake([
'api.payments.test/*' => Http::response(['status' => 'succeeded'], 200),
]);
$invoice = Invoice::factory()->sent()->create();
signIn();
$this->postJson("/api/v1/invoices/{$invoice->id}/pay")->assertOk();
expect($invoice->fresh()->status->value)->toBe('paid');
});Architecture tests
The rules from the Architecture page become executable. Pest presets cover the basics, and we add our conventions on top.
<?php
arch()->preset()->php(); // no dd(), dump(), var_dump()...
arch()->preset()->security(); // no eval(), md5(), unserialize()...
arch()->preset()->laravel(); // Laravel naming and folder conventions
arch('controllers stay thin')
->expect('App\Http\Controllers')
->not->toUse(['Illuminate\Support\Facades\DB', 'Illuminate\Support\Facades\Mail']);
arch('actions are final and expose handle()')
->expect('App\Actions')
->toBeFinal()
->toHaveMethod('handle');
arch('DTOs are immutable')
->expect('App\Data')
->toBeReadonly();
arch('env() is only read in config files')
->expect('env')
->not->toBeUsed(); // config/ is not scanned, app/ isBrowser tests (Pest 4)
New in Pest 4: tests in a real browser (Playwright), with the same syntax and the same factories. Keep them for critical journeys in Laravel-rendered apps. For a Next.js front end, use Playwright on the front-end side.
<?php
use App\Models\Invoice;
it('lets an accountant mark an invoice as paid', function () {
$user = signIn();
$invoice = Invoice::factory()->sent()->for($user, 'author')->create();
visit("/invoices/{$invoice->id}")
->assertSee('En attente de paiement')
->click('Marquer comme payée')
->assertSee('Payée')
->assertNoJavascriptErrors();
});
it('has no smoke on public pages', function () {
visit(['/', '/login', '/forgot-password'])->assertNoSmoke();
});Running tests
While developing, run only what you touch. Before pushing, run everything.
php artisan test --parallel # everything, on all CPU cores
./vendor/bin/pest --filter="draft" # only matching tests
./vendor/bin/pest --dirty # only tests touched by uncommitted changes
./vendor/bin/pest --bail # stop at the first failure
./vendor/bin/pest --profile # list the slowest tests
./vendor/bin/pest --coverage --min=80 # fail under 80% line coverage
./vendor/bin/pest --type-coverage --min=95
./vendor/bin/pest --mutate --parallel # do your tests really catch bugs?In CI
The same suite runs on every PR, split into parallel shards. Coverage under 80% blocks the merge.
name: api-tests
on:
pull_request:
push:
branches: [main]
jobs:
tests:
runs-on: ubuntu-latest
strategy:
matrix:
shard: [1, 2, 3]
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: '8.4'
coverage: pcov
- uses: actions/cache@v4
with:
path: vendor
key: composer-${{ hashFiles('composer.lock') }}
- run: composer install --no-interaction --prefer-dist
- run: cp .env.example .env && php artisan key:generate
- run: ./vendor/bin/pint --test
- run: ./vendor/bin/phpstan analyse --memory-limit=1G
- run: ./vendor/bin/pest --parallel --coverage --min=80 --shard=${{ matrix.shard }}/3A good test
Avoid
test('invoice', function () {
$user = User::first(); // depends on seeded data
$response = $this->post('/api/v1/invoices', [/* ... */]);
$this->assertTrue($response->status() == 201);
$this->assertTrue(Invoice::count() > 0);
// ...then tests update, delete and pay in the same test
});Do
it('creates a draft invoice', function () {
$user = signIn();
$this->postJson('/api/v1/invoices', invoicePayload())
->assertCreated()
->assertJsonPath('data.status', 'draft');
$this->assertDatabaseHas('invoices', ['author_id' => $user->id]);
});- One behavior per test, with a name that reads like a sentence:
it('rejects an invoice without lines'). - Arrange, Act, Assert: set up, act, verify, separated by a blank line.
- Independent: it passes alone, in any order, in parallel.
- Test behavior, not implementation: check the response and the database, not which methods were called.
- Precise assertions:
assertCreated()rather thanassertTrue($status == 201). - Mutation testing now and then:
pest --mutatereveals tests that catch nothing.