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/404 paths that were never asserted) are how data leaks ship to prod.
- Brittle, over-mocked tests. You mock your own
UserRepository/service with shouldReceive('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('...') or test('...'); assert with the fluent expect(...); share setup with beforeEach(). Do not write PHPUnit class FooTest extends TestCase unless 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(). Apply RefreshDatabase (transaction-wrapped) or LazilyRefreshDatabase (only migrates when a test touches the DB) in tests/Pest.php for the whole Feature directory, or uses(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-built DB::table()->insert([...]) or new 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 in tests/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-frozen now().
- Do NOT mock your own models/services into meaninglessness. Avoid
Mockery::mock(MyService::class)->shouldReceive(...) and Model::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 in Pest.php) with the presets that fit: arch()->preset()->php() (bans die/var_dump/dd-style debug output and deprecated PHP functions), arch()->preset()->security() (bans eval, extract, unserialize, weak hashing/md5/sha1, insecure randomness, etc.), and arch()->preset()->laravel() (enforces Laravel conventions — including no env() outside config). Add project invariants like arch('controllers')->expect('App\Http\Controllers')->toExtend('App\Http\Controllers\Controller').
- One behavior per test, descriptive name.
it('blocks guests from the dashboard'), not it('works'). If a test name needs "and", split it.
- Gate coverage in CI. Run
./vendor/bin/pest --coverage --min=NN to 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' => 'ada@example.com', 'password' => 'secret-pass',
])->assertRedirect();
Queue::assertPushed(SendReminder::class);
Mail::assertSent(WelcomeMail::class, fn ($m) => $m->hasTo('ada@example.com'));
$this->assertDatabaseHas('users', ['email' => 'ada@example.com']);
});
// 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
Mockery target — 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
RefreshDatabase and no factories — instantiate them directly and expect() the result; that is faster and correct.
make() over create() 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
--min and 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
1---2name: laravel-pest-testing3description: This skill should be used when the agent writes or modifies a Laravel feature (controller, route, job, command, policy, model, notification, mail) and needs tests; when it adds or edits files under tests/ or a *Test.php / Pest test file; when it touches Pest.php, phpunit.xml, or a dataset; when it mocks or fakes (Http::fake, Queue::fake, Mail::fake, Mockery, ::shouldReceive); when it asserts on database state, status codes, redirects, jobs, or mail; when it deals with authorization (401/403/404) or time-dependent logic (now(), Carbon, travel, freezeTime); or when the user mentions Pest, "tests", "coverage", "datasets", "arch test", "factory", RefreshDatabase, flaky tests, over-mocking, or "test the happy path". Use it to ship behavior-driven, non-brittle tests that cover the unhappy path and authorization, not just the happy path.4license: MIT5---67# Pest Testing Discipline89Features 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.1011## The footgun1213Two failure modes, both expensive:14151. **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`/`404` paths that were never asserted) are how data leaks ship to prod.162. **Brittle, over-mocked tests.** You mock your own `UserRepository`/service with `shouldReceive('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.1718The 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.1920## Rules21221. **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.232. **Use Pest idioms.** Define cases with `it('...')` or `test('...')`; assert with the fluent `expect(...)`; share setup with `beforeEach()`. Do not write PHPUnit `class FooTest extends TestCase` unless the file already uses that style.243. **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.254. **Reset the database with a trait, via `uses()`.** Apply `RefreshDatabase` (transaction-wrapped) or `LazilyRefreshDatabase` (only migrates when a test touches the DB) in `tests/Pest.php` for the whole `Feature` directory, or `uses(RefreshDatabase::class)` at the top of a file. NEVER leave DB tests dependent on leftover state.265. **Build data with factories, never hand-rolled inserts.** Use `User::factory()->create()` / `->make()` and relationship factories. Hand-built `DB::table()->insert([...])` or `new Model([...])->save()` in tests is brittle and skips casts/defaults — ban it.276. **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 in `tests/Datasets/`) instead of N near-identical tests.287. **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-frozen `now()`.298. **Do NOT mock your own models/services into meaninglessness.** Avoid `Mockery::mock(MyService::class)->shouldReceive(...)` and `Model::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).309. **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.3110. **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.3211. **Add architecture tests as cheap, global guardrails.** Keep a `tests/Arch.php` (or arch cases in `Pest.php`) with the presets that fit: `arch()->preset()->php()` (bans `die`/`var_dump`/`dd`-style debug output and deprecated PHP functions), `arch()->preset()->security()` (bans `eval`, `extract`, `unserialize`, weak hashing/`md5`/`sha1`, insecure randomness, etc.), and `arch()->preset()->laravel()` (enforces Laravel conventions — including no `env()` outside config). Add project invariants like `arch('controllers')->expect('App\Http\Controllers')->toExtend('App\Http\Controllers\Controller')`.3312. **One behavior per test, descriptive name.** `it('blocks guests from the dashboard')`, not `it('works')`. If a test name needs "and", split it.3413. **Gate coverage in CI.** Run `./vendor/bin/pest --coverage --min=NN` to 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.3514. **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.3637## Good vs bad3839```php40// tests/Feature/UpdatePostTest.php4142use App\Models\Post;43use App\Models\User;4445// ❌ over-mocked: tests that a method was called, not what happened.46// Passes even if the wrong post is updated or auth is missing.47it('updates a post', function () {48 $service = Mockery::mock(App\Services\PostService::class);49 $service->shouldReceive('update')->once()->andReturnTrue();50 $this->app->instance(App\Services\PostService::class, $service);5152 $this->put('/posts/1', ['title' => 'New'])->assertOk();53});54```5556```php57// ✅ exercises real code against the test DB; asserts the outcome AND authz.58use App\Models\Post;59use App\Models\User;6061it('lets the owner update their post', function () {62 $owner = User::factory()->create();63 $post = Post::factory()->for($owner)->create(['title' => 'Old']);6465 $this->actingAs($owner)66 ->put("/posts/{$post->id}", ['title' => 'New'])67 ->assertRedirect();6869 $this->assertDatabaseHas('posts', ['id' => $post->id, 'title' => 'New']);70});7172it('forbids a non-owner from updating the post', function () {73 $post = Post::factory()->create(['title' => 'Old']);74 $attacker = User::factory()->create();7576 $this->actingAs($attacker)77 ->put("/posts/{$post->id}", ['title' => 'Hacked'])78 ->assertForbidden(); // 4037980 $this->assertDatabaseHas('posts', ['id' => $post->id, 'title' => 'Old']);81});8283it('blocks guests', function () {84 $post = Post::factory()->create();8586 $this->put("/posts/{$post->id}", ['title' => 'New'])87 ->assertRedirect('/login'); // or assertUnauthorized() for an API88});89```9091```php92// ❌ hits the real network (flaky, slow, may leak keys); asserts nothing useful.93it('notifies slack', function () {94 $resp = (new App\Services\Slack)->ping('deploy ok');95 expect($resp)->toBeArray();96});97```9899```php100// ✅ fake the boundary you don't own; assert the request you sent.101use Illuminate\Support\Facades\Http;102103it('posts the deploy message to slack', function () {104 Http::fake(['hooks.slack.com/*' => Http::response(['ok' => true])]);105106 (new App\Services\Slack)->ping('deploy ok');107108 Http::assertSent(fn ($request) =>109 $request->url() === 'https://hooks.slack.com/services/T/B/x'110 && $request['text'] === 'deploy ok'111 );112});113```114115```php116// ❌ copy-pasted near-identical validation tests.117it('rejects empty email', function () {118 $this->post('/register', ['email' => ''])->assertSessionHasErrors('email');119});120it('rejects bad email', function () {121 $this->post('/register', ['email' => 'nope'])->assertSessionHasErrors('email');122});123```124125```php126// ✅ one test, table-driven with a dataset.127it('rejects invalid emails', function (string $email) {128 $this->post('/register', ['email' => $email])129 ->assertSessionHasErrors('email');130})->with([131 'empty' => '',132 'no at-sign' => 'nope',133 'no domain' => 'a@',134]);135```136137```php138// ✅ async + time boundaries faked; assert the dispatch and the side effect.139use App\Jobs\SendReminder;140use App\Mail\WelcomeMail;141use Illuminate\Support\Facades\Mail;142use Illuminate\Support\Facades\Queue;143144it('queues a reminder and welcomes the user on signup', function () {145 Queue::fake();146 Mail::fake();147 $this->freezeTime();148149 $this->post('/register', [150 'name' => 'Ada', 'email' => 'ada@example.com', 'password' => 'secret-pass',151 ])->assertRedirect();152153 Queue::assertPushed(SendReminder::class);154 Mail::assertSent(WelcomeMail::class, fn ($m) => $m->hasTo('ada@example.com'));155 $this->assertDatabaseHas('users', ['email' => 'ada@example.com']);156});157```158159```php160// tests/Arch.php — ✅ cheap global guardrails161arch()->preset()->php(); // bans die/var_dump/dd-style debug output + deprecated PHP fns162arch()->preset()->security(); // bans eval, extract, unserialize, md5/sha1, weak randomness, etc.163arch()->preset()->laravel(); // enforces Laravel conventions, incl. no env() outside config164165arch('controllers extend the base controller')166 ->expect('App\Http\Controllers')167 ->toExtend('App\Http\Controllers\Controller');168169arch('models live in the right namespace')170 ->expect('App\Models')171 ->toExtend('Illuminate\Database\Eloquent\Model');172```173174## How to verify175176Run these before claiming a feature + its tests are done:177178```bash179# 1. The whole suite must be green.180./vendor/bin/pest181182# 2. Run just the new/changed file(s) while iterating.183./vendor/bin/pest tests/Feature/UpdatePostTest.php184185# 3. Enforce a coverage floor (requires Xdebug or PCOV).186./vendor/bin/pest --coverage --min=80187188# 4. Type coverage (requires pestphp/pest-plugin-type-coverage).189./vendor/bin/pest --type-coverage --min=100190191# 5. Style stays clean.192./vendor/bin/pint --test193```194195Then audit that you did NOT reintroduce the footgun:196197```bash198# Authz coverage: each protected feature test should assert a 401/403/404 path.199grep -rEn "assertForbidden|assertUnauthorized|assertNotFound|->assertStatus\((401|403|404)\)" tests/200201# Over-mocking smell: mocking your OWN app classes is a red flag — review each hit.202grep -rEn "Mockery::|->shouldReceive\(|::partialMock\(|::spy\(" tests/203204# Hand-rolled inserts instead of factories — replace with ->factory().205grep -rEn "DB::table\(.*\)->insert|new App\\\\Models" tests/206207# External boundaries must be faked — confirm fakes exist where code calls out.208grep -rEn "Http::fake|Queue::fake|Bus::fake|Mail::fake|Notification::fake|Storage::fake|Event::fake" tests/209210# The security + php arch presets (the cheap global guardrails) must be present somewhere.211grep -rEn "preset\(\)->(security|php|laravel)\(\)" tests/212```213214Assert 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.215216## When it's OK to bend the rule217218- **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 `Mockery` target — you are isolating *their* dependency, not faking the behavior under test.219- **Unit-testing pure logic without the DB.** Value objects, formatters, and calculators need no `RefreshDatabase` and no factories — instantiate them directly and `expect()` the result; that is faster and correct.220- **`make()` over `create()`** when a test never persists — `User::factory()->make()` avoids a DB write for pure-logic assertions.221- **Lower coverage thresholds on legacy code.** Set a realistic `--min` and ratchet it up over time rather than blocking all work; never let "100% or nothing" become an excuse to ship zero tests.222- **Browser tests for genuinely JS-driven flows** (Livewire/Inertia/Vue interactions, file pickers) — there, `visit()` is the right tool, not an over-reach.223224## References225226- Pest — Writing Tests, expectations, hooks: https://pestphp.com/docs/writing-tests227- Pest — Datasets: https://pestphp.com/docs/datasets228- Pest — Architecture Testing & presets (`security`, `laravel`, `toExtend`): https://pestphp.com/docs/arch-testing229- Pest — Coverage & `--min`: https://pestphp.com/docs/coverage230- Pest — Type Coverage plugin: https://pestphp.com/docs/type-coverage231- Pest v4 — Browser Testing (`visit()`, visual regression): https://pestphp.com/docs/browser-testing232- Laravel — HTTP Tests (status/JSON/redirect assertions): https://laravel.com/docs/testing233- Laravel — Database Testing (`RefreshDatabase`, factories, `assertDatabaseHas`): https://laravel.com/docs/database-testing234- Laravel — Mocking & facade fakes (`Http`, `Queue`, `Bus`, `Mail`, `Notification`, `Event`, `Storage`, time): https://laravel.com/docs/mocking