All skills

Use when writing or reviewing Pest tests in a Labrodev Laravel project: Unit tests mirroring the code tree (Actions, Rules, Services, Data validation), end-to-end Feature tests through controllers, domain-state helpers in tests/Pest.php, or the mandatory Pest architecture test suite that mechanically enforces the playbook.

  • 1 file
  • 14.2 KB
  • MIT
  • Updated last month
  • GitHub

Use this Skill: https://skilld.dev/gh/labrodev/laravel-playbook/labrodev-testing

This session only. Nothing lands on disk.

SKILL.md

β‰ˆ86 tokens always: the name and description. β‰ˆ3.5k when used: this file.

Testing: Pest strategy and architecture tests

Part of the Labrodev playbook. The law for this component lives in the always-on labrodev-testing guideline (musts, must-nots); the per-file checklist is rules/tests.md. This skill holds the craft: anatomy, canonical templates, and edge cases.

What to test at which level

Component When to test Assert
Action Always, when it mutates state / enforces rules / coordinates objects Resulting state changes, return value, thrown named domain exception on failed guards
Pipeline-orchestrating Service When it exists Final workflow outcome, side effects (state, dispatched events) β€” not internal step order
Rule Always β€” ideal unit tests Boolean decisions across edge cases; avoid DB access when possible
Service When logic is non-trivial Inputs β†’ outputs; pure Services testable without Laravel bootstrapping
Pipeline When step order or payload transforms matter Final payload state only
Data When validation rules are non-trivial ValidationException on invalid raw input via validateAndCreate()
Job When retry/backoff/dispatch matters Delegation + queue configuration, nothing else
Event/Listener Listeners with important side effects Correct Action is called
Controller (Feature) Few, high-level, end-to-end Status/redirect, auth/authorization wiring (403s from the can-trio), record persisted

File layout β€” two suites mirroring the code tree

tests/Unit/** mirrors the full path of the class under test; tests/Feature/** mirrors the delivery structure and holds end-to-end scenarios through controllers. Every folder and subfolder aligns with the code it tests.

tests/
β”œβ”€β”€ Pest.php                                                # suite binding + global domain-state helpers
β”œβ”€β”€ ArchTest.php                                            # architecture enforcement (template below)
β”œβ”€β”€ Unit/
β”‚   β”œβ”€β”€ Core/Domain/Booking/
β”‚   β”‚   β”œβ”€β”€ Actions/BookingCreateTest.php                   # Action tests
β”‚   β”‚   β”œβ”€β”€ Rules/BookingRuleTest.php                       # Rule tests
β”‚   β”‚   └── Data/BookingDataTest.php                        # Data validation tests
β”‚   └── App/Layer/Dashboard/Booking/
β”‚       └── ViewModels/BookingIndexViewModelTest.php
└── Feature/
    └── App/Layer/Dashboard/Booking/
        └── BookingTest.php                                 # end-to-end controller scenarios

Template: tests/Pest.php with domain-state helpers

Helper naming pattern: create{Model}(array $attributes = []): {Model} β€” one global helper per aggregate the suite needs. Each helper delegates to the Core create Action so every fixture is a legal domain object.

<?php

declare(strict_types=1);

use Core\Domain\Booking\Actions\BookingCreate;
use Core\Domain\Booking\Data\BookingData;
use Core\Domain\Booking\Models\Booking;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
use Tests\TestCase;

pest()->extend(TestCase::class)
    ->use(RefreshDatabase::class)
    ->in('Feature', 'Unit');

/**
 * Build domain state through the Core Action β€” never through raw model
 * factories β€” so invariants (uuid, initial status, rule guards) hold.
 *
 * @param  array<string, mixed>  $attributes
 */
function createBooking(array $attributes = []): Booking
{
    $bookingData = BookingData::from([
        'reference' => fake()->unique()->numerify('BK-####'),
        'starts_at' => Carbon::now()->addDay()->toDateTimeString(),
        'ends_at' => Carbon::now()->addDay()->addHours(2)->toDateTimeString(),
        'guest_count' => 2,
        ...$attributes,
    ]);

    $bookingCreate = app(BookingCreate::class);

    return $bookingCreate(bookingData: $bookingData);
}

Template: Action test (happy / unhappy / edge)

<?php

declare(strict_types=1);

use Core\Domain\Booking\Actions\BookingCreate;
use Core\Domain\Booking\Data\BookingData;
use Core\Domain\Booking\Enums\BookingStatus;
use Core\Domain\Booking\Exceptions\BookingPeriodUnavailableException;
use Core\Domain\Booking\Models\Booking;
use Illuminate\Support\Carbon;

it('creates a booking with valid data', function (): void {
    $bookingData = BookingData::from([
        'reference' => 'BK-1001',
        'starts_at' => Carbon::now()->addDay()->toDateTimeString(),
        'ends_at' => Carbon::now()->addDay()->addHours(2)->toDateTimeString(),
        'guest_count' => 2,
    ]);

    $bookingCreate = app(BookingCreate::class);

    $booking = $bookingCreate(bookingData: $bookingData);

    expect($booking)->toBeInstanceOf(Booking::class)
        ->and($booking->uuid)->not->toBeEmpty()   // UUID assigned by the create Action
        ->and($booking->status)->toBe(BookingStatus::Pending);

    $this->assertDatabaseHas('bookings', ['uuid' => $booking->uuid]);
});

// Failed business gate β†’ the named domain exception. Never ValidationException.
it('fails when the period is not available', function (): void {
    createBooking(attributes: [
        'starts_at' => Carbon::now()->addDay()->toDateTimeString(),
        'ends_at' => Carbon::now()->addDay()->addHours(2)->toDateTimeString(),
    ]);

    $bookingData = BookingData::from([
        'reference' => 'BK-2002',
        'starts_at' => Carbon::now()->addDay()->toDateTimeString(),
        'ends_at' => Carbon::now()->addDay()->addHours(2)->toDateTimeString(),
        'guest_count' => 2,
    ]);

    $bookingCreate = app(BookingCreate::class);

    expect(fn (): Booking => $bookingCreate(bookingData: $bookingData))
        ->toThrow(BookingPeriodUnavailableException::class);
});

State-based ineligibility (the can-trio) is not an Action concern any more β€” it is enforced at Policy level. Cover it with a Rule unit test plus a Feature test asserting 403 (template below).

Template: Rule unit test

tests/Unit/Core/Domain/Booking/Rules/BookingRuleTest.php β€” pure in-memory checks, no database:

<?php

declare(strict_types=1);

use Core\Domain\Booking\Enums\BookingStatus;
use Core\Domain\Booking\Models\Booking;
use Core\Domain\Booking\Rules\BookingRule;

it('allows updating a draft booking', function (): void {
    $booking = new Booking();
    $booking->status = BookingStatus::Draft;

    expect(BookingRule::canUpdate($booking))->toBeTrue();
});

it('forbids removing a confirmed booking', function (): void {
    $booking = new Booking();
    $booking->status = BookingStatus::Confirmed;

    expect(BookingRule::canRemove($booking))->toBeFalse();
});

Template: Data validation test

::from() skips validation; ::validateAndCreate() runs rules(). Validation tests must use validateAndCreate() with raw input keys.

<?php

declare(strict_types=1);

use Core\Domain\Booking\Data\BookingData;
use Illuminate\Support\Carbon;
use Illuminate\Validation\ValidationException;

it('rejects a start time in the past', function (): void {
    expect(fn () => BookingData::validateAndCreate([
        'reference' => 'BK-1001',
        'starts_at' => Carbon::now()->subDay()->toDateTimeString(),
        'ends_at' => Carbon::now()->addHours(2)->toDateTimeString(),
        'guest_count' => 2,
    ]))->toThrow(ValidationException::class);
});

it('rejects a zero guest count', function (): void {
    expect(fn () => BookingData::validateAndCreate([
        'reference' => 'BK-1001',
        'starts_at' => Carbon::now()->addDay()->toDateTimeString(),
        'ends_at' => Carbon::now()->addDay()->addHours(2)->toDateTimeString(),
        'guest_count' => 0,
    ]))->toThrow(ValidationException::class);
});

Template: Feature test (end-to-end through controllers)

tests/Feature/App/Layer/Dashboard/Booking/BookingTest.php β€” the scenario enters through HTTP, exercises the controller β†’ Data β†’ Action chain, and asserts the response plus persisted state. The 403 case is where Policy-level can-trio enforcement gets covered.

<?php

declare(strict_types=1);

use App\Models\User;
use Illuminate\Support\Carbon;

it('stores a booking through the dashboard route', function (): void {
    $user = User::factory()->create();

    $response = $this->actingAs($user)->post(route('bookings.store'), [
        'reference' => 'BK-1001',
        'starts_at' => Carbon::now()->addDay()->toDateTimeString(),
        'ends_at' => Carbon::now()->addDay()->addHours(2)->toDateTimeString(),
        'guest_count' => 2,
    ]);

    $response->assertRedirect(route('bookings.index'));
    $this->assertDatabaseHas('bookings', ['reference' => 'BK-1001']);
});

it('shows a booking by uuid', function (): void {
    $user = User::factory()->create();
    $booking = createBooking();

    $this->actingAs($user)
        ->get(route('bookings.show', ['booking' => $booking->uuid]))
        ->assertOk();
});

it('forbids storing a booking without permission', function (): void {
    $user = User::factory()->create();

    $this->actingAs($user)
        ->post(route('bookings.store'), [])
        ->assertForbidden();
});

App\Models\User with its factory is the framework fixture for actingAs() β€” the one sanctioned factory use; the last test's user simply carries no permission, which is what makes the 403 assertion meaningful. Routes bind {model:uuid}, so route parameters take the model's uuid β†’ controller/route anatomy in the labrodev-controller skill; the permission contract behind the 403 β†’ see the labrodev-authorization skill. That's the whole HTTP suite for a domain: connected, delegating, guarded. Nothing more.

Template: architecture tests (paste-ready)

tests/ArchTest.php β€” pest-plugin-arch ships with Pest 4, no extra dependency. These tests turn playbook conventions into CI failures. Adjust the Core prefix if Core lives in a package namespace, and extend the ignoring() lists as the legacy zone dictates β†’ zone definition in the labrodev-core skill.

<?php

declare(strict_types=1);

arch('no debug or die calls anywhere')
    ->expect(['dd', 'dump', 'ray', 'var_dump', 'die', 'exit'])
    ->not->toBeUsed();

arch('core declares strict types everywhere')
    ->expect('Core')
    ->toUseStrictTypes();

arch('the app layer declares strict types everywhere')
    ->expect('App')
    ->toUseStrictTypes();

arch('core classes are final')
    ->expect('Core')
    ->classes()
    ->toBeFinal()
    ->ignoring([
        'Core\Shared\Models\BaseModel', // abstract shared bases are the only exception
    ]);

arch('core never imports the app side at all')
    ->expect('Core')
    ->not->toUse('App'); // includes App\Layer, App\Http, App\Models\User β€” Core uses its mirror User model

arch('no form requests outside the legacy zone')
    ->expect('Illuminate\Foundation\Http\FormRequest')
    ->not->toBeUsed()
    ->ignoring('App\Http'); // legacy vendor/starter zone β†’ labrodev-core skill

arch('the delivery layer never writes or validates on its own')
    ->expect('App\Layer')
    ->not->toUse([
        'Illuminate\Support\Facades\DB',        // transactions/writes live in Core Actions
        'Illuminate\Support\Facades\Validator', // validation lives in Data classes
    ]);

arch('actions never throw validation exceptions')
    ->expect('Core\Domain')
    ->not->toUse('Illuminate\Validation\ValidationException'); // business gates throw named domain exceptions

// Add one block per {Layer}/{Domain} controllers namespace as domains appear:
arch('dashboard booking controllers are final invokables')
    ->expect('App\Layer\Dashboard\Booking\Controllers')
    ->classes()
    ->toBeFinal()
    ->toBeInvokable();

Edge cases

  • Helper collisions: default helper values must survive repeated calls in one test (unique constraints) β€” use fake()->unique() for identifying fields and pass $attributes overrides for scenario-specific state.
  • Preconditions beyond create: build them by chaining Actions (createBooking() then $bookingStatusUpdate(booking: $booking, bookingStatus: BookingStatus::Cancelled)), never by writing model attributes directly in the test.
  • Time-sensitive Rules: freeze time with $this->travelTo(...) rather than widening date assertions; datetime conventions (Illuminate\Support\Carbon, no CarbonImmutable) β†’ see the labrodev-core skill.
  • Queued side effects: Queue::fake() in Action tests to assert a Job was pushed; the Job's own test asserts delegation and retry/backoff config only.
  • Events: Event::fake() to assert a fact was dispatched; do not fake events when a listener's side effect is the behavior under test.
  • Pure Rules/Services: when they take plain values or in-memory models, test them without the database β€” build models via new Booking() with explicit attributes only for pure in-memory checks that never persist; anything persisted goes through Actions.
  • Testing Data with relations: pass resolved model instances directly to ::from(['service' => $service, ...]) β€” caster pass-through makes this work; caster behavior β†’ see the labrodev-data skill.
  • Pipeline-orchestrating Service tests: assert the final outcome and observable side effects; asserting intermediate step order is allowed only when behavior depends on it.

Cross-skill pointers

  • Action anatomy, gate levels (Policy can-trio vs in-Action invariant guards throwing domain exceptions), transactions, UUID assignment β†’ see the labrodev-action skill.
  • Data classes, rules(), prepareForPipeline(), UUID casters β†’ see the labrodev-data skill.
  • Controller anatomy, routes, {model:uuid} binding, Inertia::flash + to_route() β†’ see the labrodev-controller skill.
  • Policies and the permission-constant contract behind authorization assertions β†’ see the labrodev-authorization skill.
  • Naming rules and named-argument invocation style used in every test β†’ see the labrodev-naming skill.
  • Pint/PHPStan/Rector configuration and the Rector β†’ Pint β†’ PHPStan gate β†’ see the labrodev-static-analysis skill.

Source: SKILL.md on GitHub

No third-party reports yet.

Signed by skilld at 872cdeb. This ties the file your Agent reads to that commit on GitHub. It does not review the instructions.

Last checked against GitHub last month.

Activeupdated last month
metadata
{
  "author": "labrodev"
}

README badge

README badge for labrodev/laravel-playbook/labrodev-testing