The Dev Log › Programming
Laravel Testing With Pest 4: Feature Tests, Datasets and Arch Tests
By Jezer Niel Blanca, Full Stack Developer ·
·
7 min read
How I test Laravel apps with Pest 4: feature tests through real routes, datasets for edge cases, fakes for the outside world, and architecture tests that keep a codebase honest.
Tests are the reason I can ship on a Friday afternoon without my stomach dropping. Nearly every Laravel project I work on uses Pest, because it strips away the ceremony of PHPUnit classes and lets a test read almost like a sentence. In this guide I'll walk through how I actually use Pest 4 day to day: feature tests that hit real routes, datasets that cover edge cases without copy and paste, fakes for the messy outside world, and architecture tests that keep a codebase honest as it grows.
Why Pest Fits Laravel So Well
Pest runs on top of PHPUnit, so everything you already know still works. What changes is the shape of your tests. Instead of a class with methods, you write closures with a description:
it('shows the pricing page', function () {
$this->get('/pricing')->assertOk();
});
That small shift matters more than it looks. When tests are cheap to write, you write more of them, and you write them for the boring paths that break most often. The $this inside the closure is your Laravel TestCase, so every HTTP helper, auth helper and database assertion is available.
The Pest.php File
The tests/Pest.php file is where you wire things up once for the whole suite. I bind the Laravel test case and database refreshing to feature tests like this:
<?php
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
pest()->extend(TestCase::class)
->use(RefreshDatabase::class)
->in('Feature');
With that in place, every file under tests/Feature gets a fresh database state and the full framework, and you never repeat the setup.
Writing Feature Tests That Matter
I test behaviour through HTTP whenever I can. A feature test that posts a form and checks the database proves the route, middleware, validation, controller and model all work together. That is far more valuable than a unit test of one method in isolation.
use App\Models\Project;
use App\Models\User;
it('lets a user create a project', function () {
$user = User::factory()->create();
$this->actingAs($user)
->post(route('projects.store'), [
'name' => 'Client Portal',
'budget' => 5000,
])
->assertRedirect(route('projects.index'));
$this->assertDatabaseHas('projects', [
'name' => 'Client Portal',
'user_id' => $user->id,
]);
});
it('prevents guests from creating projects', function () {
$this->post(route('projects.store'), ['name' => 'Nope'])
->assertRedirect(route('login'));
expect(Project::query()->count())->toBe(0);
});
A few habits I follow here:
- Always use factories. They keep tests short and make intent obvious.
- Use named routes. If a URL changes, the tests keep working.
- Test the unhappy path too. Guests, wrong owners and invalid input are where real bugs live.
Testing Inertia Responses
For Inertia apps, I also assert on the page component and the props it receives. Laravel's Inertia testing helpers make this pleasant:
use Inertia\Testing\AssertableInertia as Assert;
it('lists only the user projects', function () {
$user = User::factory()->has(Project::factory()->count(3))->create();
Project::factory()->count(2)->create();
$this->actingAs($user)
->get(route('projects.index'))
->assertInertia(fn (Assert $page) => $page
->component('Projects/Index')
->has('projects', 3)
);
});
That second factory call is the important bit. It creates projects owned by someone else, so the test proves the query is properly scoped to the current user.
Datasets: One Test, Many Cases
Validation rules are the classic place where tests get repetitive. Datasets let you describe the cases once and run the same test against each of them.
it('rejects invalid project data', function (array $payload, string $field) {
$user = User::factory()->create();
$this->actingAs($user)
->post(route('projects.store'), $payload)
->assertSessionHasErrors($field);
})->with([
'missing name' => [['budget' => 100], 'name'],
'name too long' => [['name' => str_repeat('a', 256), 'budget' => 100], 'name'],
'negative budget' => [['name' => 'Portal', 'budget' => -1], 'budget'],
'budget not numeric' => [['name' => 'Portal', 'budget' => 'lots'], 'budget'],
]);
Naming each case with a string key pays off when something fails, because the output tells you exactly which scenario broke. You can also share datasets across files by defining them with dataset('name', [...]) in a file under tests/Datasets and referencing them by name.
Datasets turn "I should really test that edge case" into a one-line change. That is the difference between edge cases being covered and being forgotten.
Faking the Outside World
Real applications send emails, dispatch jobs, call APIs and store files. None of that should happen for real in a test. Laravel's fakes swap each service for a recorder you can make assertions against.
use App\Jobs\SyncProjectToCrm;
use App\Mail\ProjectCreated;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Queue;
it('notifies and syncs when a project is created', function () {
Mail::fake();
Queue::fake();
Http::fake([
'api.example-crm.com/*' => Http::response(['id' => 'abc123'], 201),
]);
$user = User::factory()->create();
$this->actingAs($user)->post(route('projects.store'), [
'name' => 'Client Portal',
'budget' => 5000,
]);
Mail::assertQueued(ProjectCreated::class);
Queue::assertPushed(SyncProjectToCrm::class);
});
One tip: call Http::preventStrayRequests() in your test setup. Any HTTP call that you did not explicitly fake will then throw an exception, so a forgotten integration can never quietly hit a real API during your test run.
Freezing Time
Anything involving expiry dates, trials or scheduled reminders becomes flaky if it depends on the real clock. Laravel's time helpers fix that:
it('expires trials after fourteen days', function () {
$user = User::factory()->create(['trial_ends_at' => now()->addDays(14)]);
$this->travel(15)->days();
expect($user->fresh()->onTrial())->toBeFalse();
});
Architecture Tests Keep the Codebase Honest
This is my favourite Pest feature and the one most teams skip. Architecture tests assert rules about your code itself rather than its behaviour. They catch the slow drift that happens when several people work on the same project for months.
arch('no debugging calls are left behind')
->expect(['dd', 'dump', 'ray', 'var_dump'])
->not->toBeUsed();
arch('models extend Eloquent')
->expect('App\Models')
->toExtend('Illuminate\Database\Eloquent\Model');
arch('controllers do not touch the DB facade directly')
->expect('App\Http\Controllers')
->not->toUse('Illuminate\Support\Facades\DB');
arch()->preset()->laravel();
arch()->preset()->security();
The presets bundle sensible rules. The Laravel preset checks conventions such as controllers living in the right namespace, and the security preset flags risky functions like eval or md5. If one rule doesn't suit your project, you can call ->ignoring() with the namespace you want excluded rather than dropping the whole preset.
Running Tests Fast and Often
A test suite only helps if you actually run it. These are the commands I use constantly:
php artisan test --compact for a quick, quiet run of everything.
php artisan test --compact --filter="creates a project" to run a single test while I work on it.
php artisan test --parallel once the suite grows, so it uses all CPU cores.
./vendor/bin/pest --dirty to run only tests in files changed since the last commit.
I also keep the suite in CI so every pull request runs it automatically. Nobody has to remember, and nothing merges with a red build.
What I Don't Test
Being practical matters. I don't write tests for framework behaviour Laravel already covers, simple getters, or one-off scripts. I focus on business rules, permissions, money, integrations and anything a customer would notice if it broke.
Wrapping up
Pest makes testing in Laravel feel light: feature tests through real routes, datasets for edge cases, fakes for anything external, and architecture tests that stop bad patterns before they spread. Start with one feature test for your most important flow, add a dataset for its validation, and grow from there. If you want a Laravel product that is built with tests from the first commit, I'd love to build it with you and my team.
Tags: Laravel, Pest, Testing, PHP