Laravel Pest Testing for SaaS Apps: What to Test and How
By Amit Gupta··7 min read
A SaaS product changes every week, and any of those changes can quietly break sign-in, permissions or a workflow your customers depend on. Laravel Pest testing is how I catch that before they do. Pest is fast, the tests read almost like plain English, and it's pleasant enough that you'll actually keep writing them.
Below I go through what's worth testing in a Laravel SaaS, how I set Pest up, and how to test HTTP flows, permissions, Inertia pages, validation and side effects. Then architecture tests, and how to keep the suite fast once it grows.
What to test in a Laravel SaaS
Forget 100% coverage. Write tests where a bug would cost you customers or support time:
| Area | Example test | Type |
|---|---|---|
| Authentication | Users can log in, log out, reset a password | Feature |
| Authorization | A user without permission gets a 403 | Feature |
| Core workflows | Creating a project stores it and redirects | Feature |
| Validation | Invalid input returns errors for the right fields | Feature + dataset |
| Side effects | An invitation sends one notification | Feature + fake |
| Domain logic | A service calculates the right result | Unit or feature |
| Conventions | No dd() left in the code | Architecture |
I lean heavily on feature tests. They send a request through the whole app, so you get the most confidence per line of test code. I save unit tests for pure logic that doesn't need the framework at all.
Setting up Laravel Pest testing
Pest is a testing framework built on PHPUnit, with a shorter, function-based syntax. Install it together with the Laravel plugin:
composer require pestphp/pest pestphp/pest-plugin-laravel --dev --with-all-dependencies
vendor/bin/pest --init--init creates tests/Pest.php. That file binds your feature tests to Laravel's TestCase and resets the database for every test:
pest()->extend(Tests\TestCase::class)
->use(Illuminate\Foundation\Testing\RefreshDatabase::class)
->in('Feature');Next, point the test environment at fast, isolated drivers in phpunit.xml:
| Variable | Test value | Why |
|---|---|---|
DB_CONNECTION / DB_DATABASE | sqlite / :memory: | Fast, and never touches your real data |
QUEUE_CONNECTION | sync | Jobs run immediately |
MAIL_MAILER | array | No real emails |
CACHE_STORE, SESSION_DRIVER | array | No state between tests |
BCRYPT_ROUNDS | 4 | Password hashing stays fast |
In-memory SQLite is quick, but it isn't MySQL or PostgreSQL. If you use database-specific features like JSON columns or full-text search, run CI against the same engine you use in production.
Writing feature tests with Pest
A good feature test reads like a sentence. Create data with factories, act as a user, assert on the response:
use App\Models\User;
test('guests are redirected to the login page', function () {
$this->get(route('dashboard'))->assertRedirect(route('login'));
});
test('verified users can open the dashboard', function () {
$user = User::factory()->create();
$this->actingAs($user)->get(route('dashboard'))->assertOk();
});Use route names rather than URLs, so the tests survive when a URL changes. And prefer specific assertions like assertOk(), assertForbidden() and assertNotFound() over assertStatus(...). When one fails, you'll know what went wrong without reading a status code table.
Testing permissions and authorization
Authorization bugs are the ones that leak data. For every important rule, I test both sides:
test('members without permission cannot delete projects', function () {
$project = Project::factory()->create();
$this->actingAs(User::factory()->create())
->delete(route('projects.destroy', $project))
->assertForbidden();
expect($project->fresh())->not->toBeNull();
});Then write the positive case, where the user has the permission and the project really is gone. If you're on Spatie's package, $user->givePermissionTo(...) sets that up in one line. The package itself is covered in the Spatie roles and permissions guide.
Testing Inertia pages
With Inertia, a controller returns a page component and props rather than HTML. assertInertia() lets you check both:
use Inertia\Testing\AssertableInertia as Assert;
test('the projects page lists the projects', function () {
Project::factory()->count(3)->create();
$this->actingAs(User::factory()->create())
->get(route('projects.index'))
->assertInertia(fn (Assert $page) => $page
->component('projects/Index')
->has('projects', 3)
);
});By default, component() also checks that the page file exists (inertia.testing.ensure_pages_exist). So if someone renames a Vue, React or Svelte page, the test fails instead of the browser. This is also where I'd assert that sensitive fields, such as tokens or 2FA secrets, are missing from the props. It's an easy leak to miss by eye.
Validation with datasets
Datasets run the same test with different inputs, which makes them a natural fit for validation rules:
test('project names are validated', function (mixed $name) {
$this->actingAs(User::factory()->create())
->post(route('projects.store'), ['name' => $name])
->assertSessionHasErrors('name');
})->with([
'missing' => [null],
'too long' => [str_repeat('a', 256)],
'not a string' => [['array']],
]);Each named case shows up on its own line in the output, so you can see straight away which rule broke.
Faking emails, notifications and queues
A SaaS sends invitations, receipts and alerts all the time. You want to prove they're sent without actually sending anything. Laravel's fakes swap the real implementation for an in-memory recorder:
use Illuminate\Support\Facades\Notification;
test('inviting a user sends one invitation', function () {
Notification::fake();
$this->actingAs(User::factory()->create())->post(route('users.store'), [
'name' => 'New User',
'email' => 'new@example.com',
'send_invitation' => true,
]);
$invited = User::where('email', 'new@example.com')->firstOrFail();
Notification::assertSentTo($invited, UserInvitation::class);
});Mail::fake(), Queue::fake(), Event::fake() and Http::fake() work the same way. Http::fake() earns its keep in code that talks to payment providers or other external APIs.
Architecture tests
Architecture tests check your code's structure rather than its behaviour. You describe a rule once and Pest checks every class against it:
arch('no debugging calls')
->expect(['dd', 'dump', 'ray'])
->not->toBeUsed();
arch('models extend Eloquent')
->expect('App\Models')
->toExtend(Illuminate\Database\Eloquent\Model::class);Pest also ships presets such as arch()->preset()->php() and arch()->preset()->laravel(). They're opinionated. The Laravel preset, for example, bans env() outside config, only allows resource-style public methods on controllers, and only looks at the App namespace. My advice: try a preset, keep the rules that fit, and write your own for the rest. In a modular Laravel architecture, rules like "this module must not use that one" stop boundaries from wearing away without anyone noticing.
Keeping the suite fast
A slow suite is a suite nobody runs. These are the habits I'd reach for.
Run in parallel with vendor/bin/pest --parallel, which splits tests across processes. While you're working, run only what you changed: --dirty picks tests with uncommitted changes, and Pest 5's --tia goes further by re-running only the tests affected by your changes and replaying the rest from cache.
In CI, shard the suite. Pest 5 can balance shards by run time: record timings with --update-shards, then run --shard=1/4 on each CI machine.
Two smaller wins round it out. LazilyRefreshDatabase only migrates when a test actually touches the database. And keep factories lean by creating only the related models a test needs, because heavy factory states are a common hidden cost.
Pair all this with static analysis and style checks in CI, as described in Larastan and Pint for Laravel code quality.
Multi-tenant apps
Tenant-aware code needs extra care: creating tenant databases, switching tenant context and cleaning up afterwards. It's a big enough topic for its own post, testing multi-tenant Laravel apps with Pest.
Frequently asked questions
Is Pest better than PHPUnit for Laravel?
Pest runs on top of PHPUnit, so they're equally capable. Pest's syntax is shorter, and it adds datasets, architecture tests and a nicer CLI. Laravel supports both, and Pest can run your existing PHPUnit test classes, so you can switch over gradually. I'd pick Pest for a new project.
Should I use SQLite or MySQL for tests?
In-memory SQLite is the fastest option and works for most apps. If you use database-specific features, or you've had bugs that only showed up on MySQL or PostgreSQL, run at least your CI suite on the production engine.
How many tests does a SaaS need?
Start with the paths that would hurt most if they broke: sign-in, permissions, tenant or account isolation, and your main workflow. After that, add a test for every bug you fix. Coverage numbers matter much less than covering the flows customers rely on.
How do I test code that calls an external API?
Wrap the API in a service class and call Http::fake() in the test so it returns a fixed response. Then assert what your code did with that response and which requests it sent. The real service never gets hit.
Pest in the SaaS Laravel kits
If you'd rather not set all of this up yourself, the SaaS Laravel kits already ship with Pest 5 and the Laravel plugin. They include feature tests for login, registration, password reset and confirmation, email verification, the two-factor challenge, profile and security settings, and the dashboard. A skipUnlessFortifyHas() helper skips auth tests cleanly when you turn a Fortify feature off. Tests run on in-memory SQLite, and the React and Svelte kits also include tenancy tests for tenant creation and tenant sign-in. composer test runs Pint, frontend linting, Larastan and Pest, and a GitHub Actions workflow runs the same checks. The testing documentation has the commands and examples.