Skip to content

Instantly share code, notes, and snippets.

@nickperkins
Created March 7, 2026 10:10
Show Gist options
  • Select an option

  • Save nickperkins/416bf1ed9b333ad176d203778387c559 to your computer and use it in GitHub Desktop.

Select an option

Save nickperkins/416bf1ed9b333ad176d203778387c559 to your computer and use it in GitHub Desktop.
An agent skill for testing in a Laravel project
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.

When to use this skill

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

When NOT to use this skill

  • For Playwright E2E tests (use the e2e-test-writer skill instead)
  • For non-test code changes that don't need test coverage decisions

The Testing Pyramid (Martin Fowler)

         /‾‾‾‾\          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.

Layer Definitions — What Belongs Where

Unit Tests (tests/Unit/)

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., NoHtmlTags rule)
  • 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);

Feature Tests (tests/Feature/)

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);
});

E2E Tests (tests/Playwright/)

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)

Decision Flowchart

When writing a new test, ask these questions in order:

  1. Does it test pure logic with no framework/DB dependency? → Unit test
  2. Does it test an HTTP request, DB interaction, or framework feature? → Feature test
  3. Does it REQUIRE a real browser (JS, visual, keyboard, a11y)? → E2E test
  4. Is it already covered by a test at a lower level? → Don't write it

What NOT to Test (Delete on Sight)

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: The Grey Area

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.

Test File Organisation

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

Naming Conventions

  • 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

Maintenance Rules

  1. Every test must justify its existence. Ask: "If this test disappeared, would a real bug go undetected?" If no → delete it.
  2. No test should break from whitespace changes. If reformatting a Blade template breaks a test, the test is wrong.
  3. 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.
  4. Prefer fewer, richer tests over many thin ones. One test that exercises create → read → update → delete is worth more than four single-operation tests.
  5. Service tests belong in Feature. If it uses RefreshDatabase, it's integration, period.

References

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment