Pest Testing Discipline
Features shipped untested โ or "tested" with over-mocked suites that assert implementation details โ break silently in production and rot on every refactor. This skill makes you write Pest 3/4 tests that prove behavior, cover the unhappy path and authorization, and survive refactoring.
The footgun
Two failure modes, both expensive:
- No tests / happy-path-only tests. The feature works in the demo, then in production a guest hits the endpoint, a tenant reads another tenant's data, a validation gap lets garbage in, or a 404 becomes a 500. Authorization holes (
403/404paths that were never asserted) are how data leaks ship to prod. - Brittle, over-mocked tests. You mock your own
UserRepository/service withshouldReceive('save')->once(), so the test passes even when the real query is wrong, and it explodes the moment anyone renames a method. The suite is green, gives false confidence, and is deleted in frustration during the next refactor โ leaving you back at mode 1.
The fix is the same discipline either way: fake the boundaries you don't own, exercise the code you do own, and assert observable outcomes (DB rows, HTTP status, dispatched jobs/mail) โ including the failure and authorization outcomes.
Rules
- Write the test with the feature, not "later". A controller/job/command/policy change is incomplete until it has a Pest test. Never report a feature done without a corresponding test asserting its behavior.
- Use Pest idioms. Define cases with
it('...')ortest('...'); assert with the fluentexpect(...); share setup withbeforeEach(). Do not write PHPUnitclass FooTest extends TestCaseunless the file already uses that style. - Pick the right type. HTTP/end-to-end behavior โ Feature test (boots the framework, hits routes via
$this->get/post/...). Pure logic with no framework/DB โ Unit test. Default to a Feature test when in doubt โ it catches more. - Reset the database with a trait, via
uses(). ApplyRefreshDatabase(transaction-wrapped) orLazilyRefreshDatabase(only migrates when a test touches the DB) intests/Pest.phpfor the wholeFeaturedirectory, oruses(RefreshDatabase::class)at the top of a file. NEVER leave DB tests dependent on leftover state. - Build data with factories, never hand-rolled inserts. Use
User::factory()->create()/->make()and relationship factories. Hand-builtDB::table()->insert([...])ornew Model([...])->save()in tests is brittle and skips casts/defaults โ ban it. - Deduplicate with datasets, not copy-paste. When the same assertion runs over many inputs (valid/invalid payloads, role matrices), use
->with([...])(inline or a named dataset intests/Datasets/) instead of N near-identical tests. - Fake the boundaries you don't own. For anything external or async, use the facade fakes before the action:
Http::fake(),Queue::fake(),Bus::fake(),Mail::fake(),Notification::fake(),Event::fake(),Storage::fake(). For time, use$this->travel(...),$this->travelTo(...), or$this->freezeTime()โ never assert against an un-frozennow(). - Do NOT mock your own models/services into meaninglessness. Avoid
Mockery::mock(MyService::class)->shouldReceive(...)andModel::shouldReceive(...)for code under test. Let the real code run against the test DB and the faked boundaries. Mock your own class only at a genuine seam you are not exercising (e.g. a slow third-party SDK wrapper). - Assert outcomes, not internals. Prefer
assertDatabaseHas/assertDatabaseMissing/assertModelExists,assertStatus/assertOk/assertCreated,assertJson/assertJsonPath,assertRedirect,Mail::assertSent,Notification::assertSentTo,Queue::assertPushed,Bus::assertDispatched,Storage::disk()->assertExists. Do not assert "method X was called once" as a proxy for behavior. - Always test the unhappy path AND authorization. Every endpoint/action needs, at minimum: the happy path, a validation/failure path, and the auth paths โ guest โ
401/redirect, wrong user/role โ403, missing/foreign resource โ404. For any security-sensitive feature add an explicit "another user cannot access/modify this" test. This is non-negotiable for anything touching ownership or tenancy. - Add architecture tests as cheap, global guardrails. Keep a
tests/Arch.php(or arch cases inPest.php) with the presets that fit:arch()->preset()->php()(bansdie/var_dump/dd-style debug output and deprecated PHP functions),arch()->preset()->security()(banseval,extract,unserialize, weak hashing/md5/sha1, insecure randomness, etc.), andarch()->preset()->laravel()(enforces Laravel conventions โ including noenv()outside config). Add project invariants likearch('controllers')->expect('App\Http\Controllers')->toExtend('App\Http\Controllers\Controller'). - One behavior per test, descriptive name.
it('blocks guests from the dashboard'), notit('works'). If a test name needs "and", split it. - Gate coverage in CI. Run
./vendor/bin/pest --coverage --min=NNto enforce a minimum, and./vendor/bin/pest --type-coverage(requires the type-coverage plugin) to enforce typed signatures. Treat new untested lines as a defect. - Browser/E2E sparingly (Pest 4). Pest 4 ships browser testing via
visit('/')->...->assertSee(...)(Playwright-backed). Reserve it for true full-stack/JS flows; do not reimplement HTTP-layer assertions as slow browser tests.
Good vs bad
// tests/Feature/UpdatePostTest.php
use App\Models\Post;
use App\Models\User;
// โ over-mocked: tests that a method was called, not what happened.
// Passes even if the wrong post is updated or auth is missing.
it('updates a post', function () {
$service = Mockery::mock(App\Services\PostService::class);
$service->shouldReceive('update')->once()->andReturnTrue();
$this->app->instance(App\Services\PostService::class, $service);
$this->put('/posts/1', ['title' => 'New'])->assertOk();
});
// โ
exercises real code against the test DB; asserts the outcome AND authz.
use App\Models\Post;
use App\Models\User;
it('lets the owner update their post', function () {
$owner = User::factory()->create();
$post = Post::factory()->for($owner)->create(['title' => 'Old']);
$this->actingAs($owner)
->put("/posts/{$post->id}", ['title' => 'New'])
->assertRedirect();
$this->assertDatabaseHas('posts', ['id' => $post->id, 'title' => 'New']);
});
it('forbids a non-owner from updating the post', function () {
$post = Post::factory()->create(['title' => 'Old']);
$attacker = User::factory()->create();
$this->actingAs($attacker)
->put("/posts/{$post->id}", ['title' => 'Hacked'])
->assertForbidden(); // 403
$this->assertDatabaseHas('posts', ['id' => $post->id, 'title' => 'Old']);
});
it('blocks guests', function () {
$post = Post::factory()->create();
$this->put("/posts/{$post->id}", ['title' => 'New'])
->assertRedirect('/login'); // or assertUnauthorized() for an API
});
// โ hits the real network (flaky, slow, may leak keys); asserts nothing useful.
it('notifies slack', function () {
$resp = (new App\Services\Slack)->ping('deploy ok');
expect($resp)->toBeArray();
});
// โ
fake the boundary you don't own; assert the request you sent.
use Illuminate\Support\Facades\Http;
it('posts the deploy message to slack', function () {
Http::fake(['hooks.slack.com/*' => Http::response(['ok' => true])]);
(new App\Services\Slack)->ping('deploy ok');
Http::assertSent(fn ($request) =>
$request->url() === 'https://hooks.slack.com/services/T/B/x'
&& $request['text'] === 'deploy ok'
);
});
// โ copy-pasted near-identical validation tests.
it('rejects empty email', function () {
$this->post('/register', ['email' => ''])->assertSessionHasErrors('email');
});
it('rejects bad email', function () {
$this->post('/register', ['email' => 'nope'])->assertSessionHasErrors('email');
});
// โ
one test, table-driven with a dataset.
it('rejects invalid emails', function (string $email) {
$this->post('/register', ['email' => $email])
->assertSessionHasErrors('email');
})->with([
'empty' => '',
'no at-sign' => 'nope',
'no domain' => 'a@',
]);
// โ
async + time boundaries faked; assert the dispatch and the side effect.
use App\Jobs\SendReminder;
use App\Mail\WelcomeMail;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Queue;
it('queues a reminder and welcomes the user on signup', function () {
Queue::fake();
Mail::fake();
$this->freezeTime();
$this->post('/register', [
'name' => 'Ada', 'email' => '[email protected]', 'password' => 'secret-pass',
])->assertRedirect();
Queue::assertPushed(SendReminder::class);
Mail::assertSent(WelcomeMail::class, fn ($m) => $m->hasTo('[email protected]'));
$this->assertDatabaseHas('users', ['email' => '[email protected]']);
});
// tests/Arch.php โ โ
cheap global guardrails
arch()->preset()->php(); // bans die/var_dump/dd-style debug output + deprecated PHP fns
arch()->preset()->security(); // bans eval, extract, unserialize, md5/sha1, weak randomness, etc.
arch()->preset()->laravel(); // enforces Laravel conventions, incl. no env() outside config
arch('controllers extend the base controller')
->expect('App\Http\Controllers')
->toExtend('App\Http\Controllers\Controller');
arch('models live in the right namespace')
->expect('App\Models')
->toExtend('Illuminate\Database\Eloquent\Model');
How to verify
Run these before claiming a feature + its tests are done:
# 1. The whole suite must be green.
./vendor/bin/pest
# 2. Run just the new/changed file(s) while iterating.
./vendor/bin/pest tests/Feature/UpdatePostTest.php
# 3. Enforce a coverage floor (requires Xdebug or PCOV).
./vendor/bin/pest --coverage --min=80
# 4. Type coverage (requires pestphp/pest-plugin-type-coverage).
./vendor/bin/pest --type-coverage --min=100
# 5. Style stays clean.
./vendor/bin/pint --test
Then audit that you did NOT reintroduce the footgun:
# Authz coverage: each protected feature test should assert a 401/403/404 path.
grep -rEn "assertForbidden|assertUnauthorized|assertNotFound|->assertStatus\((401|403|404)\)" tests/
# Over-mocking smell: mocking your OWN app classes is a red flag โ review each hit.
grep -rEn "Mockery::|->shouldReceive\(|::partialMock\(|::spy\(" tests/
# Hand-rolled inserts instead of factories โ replace with ->factory().
grep -rEn "DB::table\(.*\)->insert|new App\\\\Models" tests/
# External boundaries must be faked โ confirm fakes exist where code calls out.
grep -rEn "Http::fake|Queue::fake|Bus::fake|Mail::fake|Notification::fake|Storage::fake|Event::fake" tests/
# The security + php arch presets (the cheap global guardrails) must be present somewhere.
grep -rEn "preset\(\)->(security|php|laravel)\(\)" tests/
Assert in tests, not just code: for every new endpoint confirm there is (a) a happy-path outcome assertion (assertDatabaseHas/assertOk/assertJsonPath), (b) at least one failure/validation assertion, and (c) the guest + wrong-user authorization assertions. If any is missing, the feature is not done.
When it's OK to bend the rule
- Mocking your own code at a true external seam. A thin wrapper around a paid third-party SDK (SMS, payment gateway) that you cannot fake at the HTTP layer is a legitimate
Mockerytarget โ you are isolating their dependency, not faking the behavior under test. - Unit-testing pure logic without the DB. Value objects, formatters, and calculators need no
RefreshDatabaseand no factories โ instantiate them directly andexpect()the result; that is faster and correct. make()overcreate()when a test never persists โUser::factory()->make()avoids a DB write for pure-logic assertions.- Lower coverage thresholds on legacy code. Set a realistic
--minand ratchet it up over time rather than blocking all work; never let "100% or nothing" become an excuse to ship zero tests. - Browser tests for genuinely JS-driven flows (Livewire/Inertia/Vue interactions, file pickers) โ there,
visit()is the right tool, not an over-reach.
References
- Pest โ Writing Tests, expectations, hooks: https://pestphp.com/docs/writing-tests
- Pest โ Datasets: https://pestphp.com/docs/datasets
- Pest โ Architecture Testing & presets (
security,laravel,toExtend): https://pestphp.com/docs/arch-testing - Pest โ Coverage &
--min: https://pestphp.com/docs/coverage - Pest โ Type Coverage plugin: https://pestphp.com/docs/type-coverage
- Pest v4 โ Browser Testing (
visit(), visual regression): https://pestphp.com/docs/browser-testing - Laravel โ HTTP Tests (status/JSON/redirect assertions): https://laravel.com/docs/testing
- Laravel โ Database Testing (
RefreshDatabase, factories,assertDatabaseHas): https://laravel.com/docs/database-testing - Laravel โ Mocking & facade fakes (
Http,Queue,Bus,Mail,Notification,Event,Storage, time): https://laravel.com/docs/mocking