| name | testing |
|---|---|
| description | Write and review Pest tests (Unit & Feature) following Martin Fowler's testing pyramid. Use this skill when creating, modifying, or reviewing any Pest test to ensure it provides genuine value at the correct layer. Prevents test bloat, fragile assertions, and misplaced test levels. |
Use this skill when you need to:
- Write new Pest tests for any feature or bug fix
- Review existing tests for quality, placement, or value
- Decide whether a test belongs at Unit, Feature, or E2E level
- Refactor tests that are fragile, duplicated, or misplaced
- For Playwright E2E tests (use the
e2e-test-writerskill instead) - For non-test code changes that don't need test coverage decisions
/‾‾‾‾\ E2E (Playwright): very few, real browser
/ few \ Test user JOURNEYS that require JS/browser
/‾‾‾‾‾‾‾‾\
/ moderate \ Feature (Pest): moderate count, HTTP + DB
/ integration \ Test controller flows, policies, middleware
/‾‾‾‾‾‾‾‾‾‾‾‾‾‾\
/ many fast \ Unit (Pest): many, fast, isolated
/ pure logic & rules \ Test services, models, helpers — NO DB ideally
/________________________\
The pyramid is a COST model. Each layer up costs more to run, more to maintain, and more to debug when broken. Push tests DOWN the pyramid as far as possible.
Purpose: Test pure business logic in isolation. Fast. No framework boot. No database ideally.
MUST test:
- Service method logic (calculations, transformations, decision branches)
- Model accessor/mutator logic (computed attributes, custom getters)
- Value objects, enums, helper functions
- Rules and validation logic (e.g.,
NoHtmlTagsrule) - Scopes as query-builder assertions (test the SQL, not the result)
MUST NOT test:
- Database CRUD operations (that's Feature level)
- HTTP request/response cycles (that's Feature level)
- Eloquent relationship definitions (test via
new Model, never via DB) - Factory output shapes (factories are test infrastructure, not production code)
- Framework configuration (that's not a test — it's documentation)
How to verify placement: If your unit test uses RefreshDatabase, calls $this->get()/$this->post(), or requires Organisation::factory()->create() to function, it is NOT a unit test. Move it to Feature.
Anti-patterns to REJECT:
// BAD: Testing Eloquent internals — Laravel already tests this
it('has correct fillable attributes', function () {
expect((new Volunteer)->getFillable())->toContain('name');
});
// BAD: Testing casts — framework behaviour, not your code
it('casts is_active to boolean', function () {
$v = Volunteer::factory()->create(['is_active' => 1]);
expect($v->is_active)->toBeBool(); // Tests Eloquent, not your app
});
// BAD: Testing a relationship via DB — this is an integration test
it('belongs to organisation', function () {
$org = Organisation::factory()->create();
$v = Volunteer::factory()->create(['organisation_id' => $org->id]);
expect($v->organisation->id)->toBe($org->id); // Needs DB = not unit
});Correct unit test patterns:
// GOOD: Testing business logic
it('calculates available shift slots', function () {
$service = new SlotCalculator();
expect($service->available(total: 10, filled: 7))->toBe(3);
});
// GOOD: Testing a scope builds correct SQL (no DB needed)
it('scopes to active volunteers', function () {
$query = Volunteer::query()->active()->toRawSql();
expect($query)->toContain('"is_active" = true');
});
// GOOD: Testing a model accessor
it('formats full name', function () {
$v = new Volunteer(['first_name' => 'Sam', 'last_name' => 'Jones']);
expect($v->full_name)->toBe('Sam Jones');
});
// GOOD: Testing a validation rule
it('rejects HTML in input', function () {
$rule = new NoHtmlTags();
$fail = fn($msg) => throw new \Exception($msg);
$rule->validate('name', '<script>alert(1)</script>', $fail);
})->throws(\Exception::class);Purpose: Test HTTP request/response cycles, middleware enforcement, policy authorisation, database interactions, Livewire component behaviour, and service integration with the framework.
MUST test:
- Controller actions (GET renders correct view, POST creates/updates/deletes, redirects work)
- Authorization (admin can access admin routes, volunteer cannot, unauthenticated redirects to login)
- Form validation (FormRequest rules reject bad input, accept good input)
- Service integration (service creates DB records, sends notifications, invalidates caches)
- Livewire components (render, interact, emit events)
- Multi-tenancy scoping (tenant A cannot see tenant B's data)
MUST NOT test:
- Implementation details of views (exact HTML strings, CSS classes, specific markup)
- Configuration values (testing that
config('app.name')returns a string is not valuable) - That Laravel's mailer/queue/cache works (test YOUR code's interaction with these, not the framework)
Anti-patterns to REJECT:
// BAD: Asserting exact HTML — breaks when template changes
it('renders branding CSS', function () {
$response = $this->get('/');
$response->assertSee('--org-primary-colour: #FF5733');
// Breaks if whitespace, formatting, or variable name changes
});
// BAD: Testing config exists — this is documentation, not a test
it('has correct mail driver', function () {
expect(config('mail.default'))->toBe('smtp');
});
// BAD: Testing that a notification class exists
it('has volunteer approved notification', function () {
expect(class_exists(VolunteerApproved::class))->toBeTrue();
});Correct feature test patterns:
// GOOD: Testing the HTTP contract
it('creates a volunteer and redirects', function () {
$admin = User::factory()->admin()->create();
$response = $this->actingAs($admin)->post(route('admin.volunteers.store'), [
'first_name' => 'Sam', 'last_name' => 'Jones', 'email' => 'sam@example.com',
]);
$response->assertRedirect(route('admin.volunteers.index'));
$this->assertDatabaseHas('volunteers', ['first_name' => 'Sam']);
});
// GOOD: Testing authorisation
it('prevents volunteers from accessing admin routes', function () {
$volunteer = User::factory()->volunteer()->create();
$this->actingAs($volunteer)->get(route('admin.dashboard'))
->assertForbidden();
});
// GOOD: Testing tenant scoping
it('does not leak data across tenants', function () {
$orgA = Organisation::factory()->create();
$orgB = Organisation::factory()->create();
Event::factory()->for($orgA)->create(['title' => 'A Event']);
Event::factory()->for($orgB)->create(['title' => 'B Event']);
setTenant($orgA);
expect(Event::pluck('title')->all())->toBe(['A Event']);
});
// GOOD: Testing a service through its effects
it('sends notification when volunteer request is approved', function () {
Notification::fake();
$request = VolunteerRequest::factory()->pending()->create();
app(VolunteerRequestService::class)->approve($request);
Notification::assertSentTo($request->volunteer->user, VolunteerRequestApproved::class);
});Purpose: Validate real user journeys in a real browser. Test flows that REQUIRE JavaScript, visual rendering, or cross-page navigation that depends on client-side state.
Do this at E2E level:
- Flows requiring Alpine.js/Livewire interactive behaviour (modals, dropdowns, inline editing)
- Accessibility audits (axe-core scans)
- Visual regression screenshots
- Keyboard navigation and focus management
- Mobile viewport measurements and responsive behaviour
- Multi-step cross-role journeys (volunteer signs up → admin approves → volunteer sees assignment)
Do NOT do this at E2E level:
- CRUD form submissions (POST + redirect = Feature test)
- Page load + text presence checks (GET + assertSee = Feature test)
- HTTP status code checks (assertStatus = Feature test)
- Route/middleware enforcement (Feature test)
When writing a new test, ask these questions in order:
- Does it test pure logic with no framework/DB dependency? → Unit test
- Does it test an HTTP request, DB interaction, or framework feature? → Feature test
- Does it REQUIRE a real browser (JS, visual, keyboard, a11y)? → E2E test
- Is it already covered by a test at a lower level? → Don't write it
These provide zero value and actively harm the suite:
| Anti-pattern | Why it's harmful |
|---|---|
getFillable() assertions |
Tests Eloquent internals, not your code |
getCasts() assertions |
Same — framework behaviour |
| Relationship existence via DB | Test via new Model reflections or trust your migration |
| Config value assertions | Config files ARE the assertion — reading them IS the test |
class_exists() checks |
Autoloading works; this tests PHP, not your app |
| Factory shape assertions | Factories are test infra, not prod code |
| Exact HTML string matching | Couples tests to template whitespace |
| Asserting log channel names | Configuration, not behaviour |
| Testing that migrations run | RefreshDatabase already proves this |
Service tests (e.g., ShiftServiceTest, VolunteerRequestServiceTest) are the most commonly misplaced. They typically:
- Use
RefreshDatabase(DB dependency) - Create records via factories
- Assert database state after service calls
- Use
Notification::fake(),Cache::spy(), etc.
These are Feature/Integration tests, even though they test a service class. Place them in tests/Feature/Services/ NOT tests/Unit/Services/.
A true unit test of a service would mock its dependencies:
// True UNIT test — mocks everything
it('validates slot availability before assignment', function () {
$repo = Mockery::mock(AssignmentRepository::class);
$repo->shouldReceive('countForShift')->with(1)->andReturn(5);
$service = new AssignmentService($repo);
expect($service->hasAvailableSlots(shiftId: 1, maxSlots: 5))->toBeFalse();
});If you can't write the test without RefreshDatabase, it's a Feature test. Full stop.
tests/
├── Unit/
│ ├── Models/ # Model accessors, mutators, scopes (no DB)
│ ├── Rules/ # Validation rules
│ ├── Support/ # Helpers, utilities
│ └── Enums/ # Enum behaviour
├── Feature/
│ ├── Controllers/ # HTTP request/response tests
│ │ ├── Admin/ # Admin controller tests
│ │ └── Volunteer/ # Volunteer controller tests
│ ├── Services/ # Service integration tests (use DB)
│ ├── Livewire/ # Livewire component tests
│ ├── MultiTenancy/ # Tenant scoping, middleware, isolation
│ ├── Policies/ # Authorization policy tests
│ ├── Notifications/ # Notification dispatch tests
│ └── Security/ # Auth, rate limiting, XSS prevention
└── Playwright/
└── tests/ # E2E browser tests
- File:
ThingBeingTestedTest.php(e.g.,ShiftServiceTest.php,EventControllerTest.php) - Test:
it('verb + outcome', function () { ... })— describe behaviour, not implementation - Describe blocks: Use
describe()to group related scenarios within a file
// GOOD names
it('creates an assignment and sends notification')
it('prevents duplicate assignments for the same shift')
it('returns 403 when volunteer accesses admin route')
// BAD names
it('test create') // What does "test create" mean?
it('works correctly') // What "works"? What's "correctly"?
it('has fillable attributes') // Testing framework, not behaviour- Every test must justify its existence. Ask: "If this test disappeared, would a real bug go undetected?" If no → delete it.
- No test should break from whitespace changes. If reformatting a Blade template breaks a test, the test is wrong.
- No test should duplicate another. If Feature tests cover a flow, do NOT also cover it in E2E unless the browser interaction adds genuine value.
- Prefer fewer, richer tests over many thin ones. One test that exercises create → read → update → delete is worth more than four single-operation tests.
- Service tests belong in Feature. If it uses
RefreshDatabase, it's integration, period.