【Complete Field Guide】Laravel Testing Strategy — Pest/PHPUnit, Feature/Unit, Factories, DB, Mocking HTTP/Queue/Notification, Dusk, and Accessibility UI Regression Testing
What You’ll Learn (Key Points)
- Design guidelines for deciding “how far to test” in Laravel without hesitation (division of Unit/Feature/E2E responsibilities)
- “Hard-to-break” data setup using Factory/Seeder, and how to write readable tests
- Practical testing patterns for auth/authz, APIs, validation, exceptions, queues, notifications, and events
- Stabilizing unstable dependencies (external APIs/HTTP, time, randomness, storage) via Fake/Mock
- CI optimizations (GitHub Actions, etc.) to run fast (parallelization, test splitting, flaky test countermeasures)
- How to protect UI with Dusk, plus how to approach accessibility regression testing (focus, error display, ARIA) via concrete examples
Intended Readers (Who Benefits?)
- Laravel beginner to intermediate engineers: You want to prevent “things that break when adding features” by protecting them with tests
- Tech leads / ops owners: You want CI-backed quality that reduces review load and incidents
- QA / designers / accessibility specialists: You want to continuously protect an “operable experience” in forms and lists
Accessibility Level: ★★★★★
This guide explains—using concrete examples—how to protect UI regression around focus movement, error summaries,
aria-invalid,aria-describedby, androle="status"/alertvia Dusk and static checks.
1. Introduction: Tests Exist to Create “Safe-to-Change” Development
Laravel development moves fast—and that means changes happen often. In change-heavy projects, the most painful thing is: “I fixed it, but something else broke.” Tests are not a brake that slows feature work; they are an accelerator. When you can change confidently, improvements and refactors become much easier.
But if you try to test everything, you burn out. The real key is deciding the order of writing tests and the critical areas you must protect. This article organizes practical Laravel testing patterns across Unit/Feature/E2E (Dusk), and goes further by covering accessibility regression as well.
2. Decide First: Dividing Responsibilities Across Unit/Feature/E2E
Unit (Unit Tests)
- Quickly verify small classes (services, validation rules, aggregation logic)
- Do not touch DB or HTTP (if you do, move it to Feature)
Feature (Application Behavior)
- Covers route → middleware → controller → DB → response
- Fixes auth/authz, validation, exceptions, JSON responses as “specifications”
- In Laravel, this is usually the easiest to write and gives the best ROI
E2E (Browser, Dusk)
- Protect UI regression (forms, sorting, pagination, accessible error display, etc.)
- Too many becomes slow, so it’s best to focus on key user journeys
Recommended ratio (rough guideline)
- Feature: 7
- Unit: 2
- Dusk: 1
This balance tends to achieve both speed and confidence.
3. The Foundation: Factories and “Readable Data Building”
If tests are hard to read, they won’t be maintained—and they’ll rot. The trick is to use factories to generate data where the “intent” is visible.
3.1 Factory Example (User and Role)
// database/factories/UserFactory.php
public function definition(): array
{
return [
'name' => $this->faker->name(),
'email' => $this->faker->unique()->safeEmail(),
'password' => bcrypt('StrongPassw0rd!'),
'tenant_id' => Tenant::factory(),
];
}
public function admin(): static
{
return $this->afterCreating(function (User $user) {
$user->assignRole('admin');
});
}
3.2 Use Names That Explain the Situation
In tests, variable names matter more than being short.
- Instead of
$user, use$adminUser - Instead of
$p, use$otherTenantProject
This alone reduces maintenance cost substantially.
4. Feature Test Basics: Fix HTTP and DB as “Specifications”
4.1 A Page That Requires Authentication
public function test_dashboard_requires_login()
{
$res = $this->get('/dashboard');
$res->assertRedirect('/login');
}
4.2 정상 표시 for Logged-In Users
public function test_dashboard_shows_for_logged_in_user()
{
$user = User::factory()->create();
$this->actingAs($user);
$res = $this->get('/dashboard');
$res->assertOk()->assertSee('ダッシュボード');
}
4.3 Validation (422)
public function test_store_rejects_invalid_input()
{
$user = User::factory()->create();
$this->actingAs($user);
$res = $this->post('/projects', ['name' => '']); // required violation
$res->assertSessionHasErrors(['name']);
}
4.4 Creation Success (PRG + DB)
public function test_store_creates_project()
{
$user = User::factory()->create();
$this->actingAs($user);
$res = $this->post('/projects', ['name' => '新規プロジェクト']);
$res->assertRedirect('/projects');
$this->assertDatabaseHas('projects', [
'name' => '新規プロジェクト',
'tenant_id' => $user->tenant_id,
]);
}
Notes
- For pages,
assertSessionHasErrorsfeels natural. - For APIs,
postJson()+ fixing the JSON structure is very powerful (covered later).
5. Authorization (Policy/Gate) Tests: The Most Important Place to Prevent Boundary Leaks
Multi-tenancy and permissions have a huge blast radius if they fail, so protecting them with Feature tests has high value.
public function test_user_cannot_update_other_tenant_project()
{
$t1 = Tenant::factory()->create();
$t2 = Tenant::factory()->create();
$user = User::factory()->create(['tenant_id' => $t1->id]);
$other = Project::factory()->create(['tenant_id' => $t2->id]);
$this->actingAs($user);
app()->instance('tenant', $t1);
$res = $this->patch("/projects/{$other->id}", ['name' => '侵入']);
$res->assertForbidden();
}
For extra robustness, also verify:
- A
403is returned - The database did not change
That makes it even harder for regressions to slip in.
6. API Tests: Turn JSON Responses into a “Contract”
6.1 Fixing a Success Response
public function test_api_returns_orders()
{
$user = User::factory()->create();
$this->actingAs($user);
Order::factory()->count(3)->create(['user_id' => $user->id]);
$res = $this->getJson('/api/v1/orders');
$res->assertOk()
->assertJsonStructure([
'data' => [
'*' => ['id','status','total']
],
'meta' => ['page','per_page']
]);
}
6.2 Fixing an Error Format (problem+json)
If your API standardizes on application/problem+json, this becomes one of the strongest guards.
public function test_api_validation_returns_problem_json()
{
$user = User::factory()->create();
$this->actingAs($user);
$res = $this->postJson('/api/v1/orders', ['items' => []]);
$res->assertStatus(422)
->assertHeader('Content-Type', 'application/problem+json')
->assertJsonStructure(['type','title','status','detail','errors','trace_id']);
}
7. Stabilize Unstable Dependencies with Fake/Mock (HTTP/Notifications/Mail/Events)
One of Laravel’s strengths is how many fakes it provides for practical tests.
7.1 External API (HTTP) Fake
use Illuminate\Support\Facades\Http;
public function test_external_api_is_called()
{
Http::fake([
'api.example.com/*' => Http::response(['ok' => true], 200),
]);
$res = $this->post('/sync');
$res->assertRedirect();
Http::assertSent(function ($req) {
return str_contains($req->url(), 'api.example.com') && $req->method() === 'POST';
});
}
7.2 Notifications
use Illuminate\Support\Facades\Notification;
public function test_notification_is_sent()
{
Notification::fake();
$user = User::factory()->create();
$this->post('/password/reset', ['email' => $user->email]);
Notification::assertSentTo($user, \App\Notifications\ResetPassword::class);
}
7.3 Mail
use Illuminate\Support\Facades\Mail;
public function test_invoice_mail_is_queued()
{
Mail::fake();
$user = User::factory()->create();
$this->actingAs($user);
$this->post('/invoices', ['plan' => 'pro']);
Mail::assertQueued(\App\Mail\InvoiceCreated::class);
}
7.4 Events
use Illuminate\Support\Facades\Event;
public function test_event_dispatched_on_create()
{
Event::fake();
$user = User::factory()->create();
$this->actingAs($user);
$this->post('/projects', ['name'=>'A']);
Event::assertDispatched(\App\Events\ProjectCreated::class);
}
8. Time, Randomness, Queues: Small Tricks for Reproducibility
8.1 Fixing Time (Makes Tests Stable)
use Illuminate\Support\Carbon;
public function test_due_date_is_calculated()
{
Carbon::setTestNow('2026-01-14 10:00:00');
$due = app(DueDateCalculator::class)->forPlan('pro');
$this->assertEquals('2026-02-14', $due->toDateString());
}
8.2 Queue Fake
use Illuminate\Support\Facades\Queue;
public function test_job_dispatched()
{
Queue::fake();
$this->post('/export');
Queue::assertPushed(\App\Jobs\ExportCsv::class);
}
9. Running DB Tests Fast: RefreshDatabase and “Make Only What Needs to Be Heavy” Heavy
RefreshDatabaseis readable, but DB resets can be costly- If speed is critical, splitting tests and using DB only where needed can help
use Illuminate\Foundation\Testing\RefreshDatabase;
class ProjectTest extends TestCase
{
use RefreshDatabase;
}
Also, creating too much factory data slows tests down, so default to:
- Minimal necessary counts
- Aggregations with 1–3 records
Run performance tests separately so regular CI stays light.
10. What Dusk Should Protect: Critical Flows and Accessibility
Dusk is powerful but gets slow if overused. A good approach is to protect only these flows:
- Login
- Primary creation forms (signup/purchase/application)
- Filtering/sorting on important lists
- Critical error displays (validation summary + focus behavior)
10.1 Example: Focus Moves to the Error Summary
public function test_error_summary_focuses_on_invalid_submit()
{
$this->browse(function (\Laravel\Dusk\Browser $b) {
$b->visit('/register')
->type('email', 'not-an-email')
->press('登録')
->assertPresent('#error-summary')
->assertFocused('#error-summary'); // verifies focus moved
});
}
10.2 Example: aria-invalid Is Applied
public function test_aria_invalid_set_on_error()
{
$this->browse(function (\Laravel\Dusk\Browser $b) {
$b->visit('/register')
->press('登録')
->assertAttribute('#email', 'aria-invalid', 'true');
});
}
You can’t realistically cover “all accessibility” via Dusk, but these are regression-prone and worth guarding:
- Error summaries
- Required field errors
- Completion/progress announcements (
role="status")
11. Tips for “Unbreakable Tests”: Flaky Countermeasures and Small Design Choices
11.1 Don’t Depend on Randomness
- Don’t over-rely on Faker random values
- Use fixed strings or
state() - Keep expectations deterministic
11.2 Don’t Depend on Time
- Use
Carbon::setTestNow()to fix time-based behavior
11.3 Use Appropriate Waiting in Dusk
- Prefer
waitForText/waitForinstead of piling uppause() - Make “done conditions” explicit (e.g., add
data-testid="loaded")
12. Running Fast in CI: A Minimal Example and Operational Thinking
12.1 Recommended Order for Speed
- Static analysis (PHPStan/Pint)
- Unit/Feature
- Dusk (key flows only)
- Contract tests if you have bandwidth (OpenAPI diffs)
12.2 How to Split Tests
- For normal PRs: run Unit/Feature only
- On main branch merges: run Dusk too
- Nightly: run heavy tests (large data, performance)
This keeps daytime development speed high.
13. Leave “Samples” in Tests: Documentation Value
Tests are specifications. Especially for these cases, tests are often the clearest documentation:
- Authorization boundaries (who can do what)
- Error formats (API contracts)
- Validation wording (input UX)
- Idempotency and double-submit protection (preventing incidents)
The ideal state is: when reviewers ask “what’s the spec?”, you can point to a test.
14. Common Pitfalls and How to Avoid Them
- Over-depending on UI copy (many failures on small text edits)
- Avoid: assert only key text; focus on structure (status, presence, rules)
- Huge test data slows everything down
- Avoid: keep counts minimal; keep aggregation data small; move heavy tests to a separate suite
- Calling external APIs for real
- Avoid: always use HTTP fakes; fake failure patterns too
- No authorization tests
- Avoid: always cover tenant boundaries and role boundaries with Feature tests
- Unstable Dusk runs
- Avoid: explicit wait conditions, eliminate time dependence, keep E2E minimal
15. Checklist (For Distribution)
Test strategy
- [ ] Decided roles and ratio for Unit/Feature/Dusk
- [ ] Protected key flows (signup/purchase/application/admin) with a minimal Dusk suite
Data setup
- [ ] Factories are readable (state/admin/tenant, etc.)
- [ ] Variable names convey intent (
$adminUser, etc.)
Feature (“specs to protect”)
- [ ] Authentication (redirect/401 when not logged in)
- [ ] Authorization (403, tenant boundary)
- [ ] Validation (422, which fields fail)
- [ ] DB changes (
assertDatabaseHas/Missing)
Fakes / stabilization
- [ ] Proper fakes for HTTP/Notification/Mail/Queue/Event
- [ ] Time fixed via
Carbon::setTestNow()
Accessibility regression
- [ ] Error summary existence + focus (Dusk)
- [ ] Basics of
aria-invalid/aria-describedby(Dusk) - [ ] Success/progress
role="status"hasn’t disappeared (within needed scope)
16. Summary
In Laravel testing, the shortest path is to solidify your application “contracts” using Feature tests first. Fix authentication, authorization, validation, and API responses as specifications, and stabilize external dependencies with fakes. Use Dusk selectively—protect only critical flows—and it’s especially recommended to guard accessibility regressions like form error display and focus behavior. Once tests are in place, changes become less scary. Projects that can improve safely tend to grow faster—and more kindly.

