Claude Skill

testing-laravel

Writes Laravel tests using PHPUnit. Use when "write tests", "add tests", "phpunit", "laravel test", "feature test", "unit test", "mock", "factory", or testing controllers, models, services, actions, jobs, artisan commands, or API endpoints.

LLM Mart · 0 points · 10 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download iliaal-whetstone-distillery_generated-skills_testing-laravel-bccd699.zip · 7 KB
Part of iliaal/whetstone — 62 skills

Install

skills CLI npx skills add https://github.com/iliaal/whetstone/tree/master/distillery/generated-skills/testing-laravel
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install iliaal-whetstone@llmmart
Git git clone https://github.com/iliaal/whetstone.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole iliaal/whetstone collection as a plugin from our marketplace. Git is the plain clone.

Skill manifest

Testing Laravel

Use PHPUnit with Laravel's testing helpers. Every test file starts with declare(strict_types=1).

Test Classification

  • Feature tests (tests/Feature/): HTTP requests through the full stack — routes, controllers, middleware, validation, database. Use $this->getJson(), $this->postJson(), etc.
  • Unit tests (tests/Unit/): Isolated logic — services, actions, value objects, helpers. No HTTP, minimal database.

Default to feature tests for anything touching routes, controllers, or models. Use unit tests for pure logic and action classes.

Critical Rules

  • use RefreshDatabase trait in every test class that touches the database
  • Model factories for all test data — use factories instead of raw DB::table() inserts
  • One behavior per test method. Name with test_ prefix: test_user_can_update_own_profile
  • Assert both response status AND side effects (DB state, dispatched jobs, sent notifications)
  • actingAs($user) for auth — use this instead of manually setting sessions or tokens
  • postJson() / getJson() for API endpoints — sets proper Accept headers and returns JSON assertions
  • Fake facades BEFORE the action: Queue::fake() then act then Queue::assertPushed(...)
  • assertDatabaseHas / assertDatabaseMissing to verify persistence — use these instead of re-querying
  • Resolve action classes from the container with resolve() so DI works; use swap() to inject mocks
  • Tests expose bugs, not the reverse: If a test uncovers broken or buggy behavior, highlight the issue and propose a fix to the source code. Never adjust the test to match incorrect behavior.

PHPUnit Essentials

<?php

declare(strict_types=1);

namespace Tests\Feature;

use App\Models\{User, Post};
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

final class PostTest extends TestCase
{
    use RefreshDatabase;

    public function test_authenticated_user_can_create_post(): void
    {
        $user = User::factory()->create();

        $response = $this->actingAs($user)
            ->postJson('/api/posts', ['title' => 'New Post', 'body' => 'Content']);

        $response->assertCreated()
            ->assertJson(['data' => ['title' => 'New Post']]);

        $this->assertDatabaseHas('posts', [
            'title' => 'New Post',
            'user_id' => $user->id,
        ]);
    }
}

Data providers for boundary/validation testing:

#[DataProvider('titleLengthProvider')]
public function test_validates_title_length(string $title, bool $valid): void
{
    $user = User::factory()->create();
    $response = $this->actingAs($user)
        ->postJson('/api/posts', ['title' => $title, 'body' => 'Content']);

    $valid ? $response->assertCreated() : $response->assertUnprocessable();
}

public static function titleLengthProvider(): array
{
    return [
        'too short' => ['AB', false],
        'minimum valid' => ['ABC', true],
        'maximum valid' => [str_repeat('A', 255), true],
        'too long' => [str_repeat('A', 256), false],
    ];
}

See feature testing patterns for auth, validation, API, console, and DB assertions.

See mocking and faking for facade fakes (Queue, Event, Notification, Mail, Storage, Http), action mocking with swap(), and Mockery.

See factories for states, relationships, sequences, and afterCreating hooks.

Running Tests

For large test suites, call PHPUnit directly to avoid artisan's memory overhead:

./vendor/bin/phpunit                              # all tests (direct, lower memory)
./vendor/bin/phpunit --filter=PostTest             # by name
./vendor/bin/phpunit --processes=auto              # parallel (PHPUnit 11+)
./vendor/bin/phpunit --coverage-text --min=80      # with coverage threshold

php artisan test                                   # small suites or quick runs
php -d memory_limit=1G artisan test                # if artisan needed on large suites
Files (whetstone)
  • references
    • factories.md 2.2 KB
      # Factory Patterns
      
      ## Basic Factory
      
      ```php
      class PostFactory extends Factory
      {
          public function definition(): array
          {
              return [
                  'title' => fake()->sentence(),
                  'slug' => fake()->slug(),
                  'content' => fake()->paragraphs(3, true),
                  'published_at' => fake()->dateTimeBetween('-1 year', 'now'),
                  'user_id' => User::factory(),
                  'category_id' => Category::factory(),
              ];
          }
      }
      ```
      
      ## States
      
      Name states as adjectives or past participles — they describe what the model IS:
      
      ```php
      public function unpublished(): static
      {
          return $this->state(fn (array $attributes) => [
              'published_at' => null,
          ]);
      }
      
      public function published(): static
      {
          return $this->state(fn (array $attributes) => [
              'published_at' => now(),
          ]);
      }
      
      // Usage
      $post = Post::factory()->unpublished()->create();
      ```
      
      ## Relationships
      
      ```php
      // Has many — creates parent with 3 children
      $post = Post::factory()
          ->has(Comment::factory()->count(3))
          ->create();
      
      // Belongs to — creates children for specific parent
      $posts = Post::factory()
          ->count(3)
          ->for($user)
          ->create();
      
      // Combined
      $post = Post::factory()
          ->published()
          ->for($user)
          ->has(Comment::factory()->count(3))
          ->has(Tag::factory()->count(2))
          ->create();
      ```
      
      ## afterCreating Hooks
      
      For side effects that require a persisted model:
      
      ```php
      public function configure(): static
      {
          return $this->afterCreating(function (Post $post) {
              $post->tags()->attach(
                  Tag::factory()->count(3)->create()
              );
          });
      }
      ```
      
      ## Sequences
      
      ```php
      $users = User::factory()
          ->count(3)
          ->sequence(
              ['role' => 'admin'],
              ['role' => 'editor'],
              ['role' => 'viewer'],
          )
          ->create();
      ```
      
      ## Usage in Tests
      
      ```php
      // Single model
      $user = User::factory()->create();
      
      // With overrides
      $user = User::factory()->create(['email' => 'specific@test.com']);
      
      // Multiple
      $posts = Post::factory()->count(10)->create();
      
      // In-memory (no DB write)
      $user = User::factory()->make();
      ```
      
      Always use `create()` for feature tests (persists to DB). Use `make()` only for unit tests that need a model instance without persistence.
      
    • feature-testing.md 4.7 KB
      # Feature Testing Patterns
      
      ## Authentication Testing
      
      ```php
      public function test_authenticated_user_can_access_endpoint(): void
      {
          $user = User::factory()->create();
      
          $this->actingAs($user)
              ->getJson('/api/profile')
              ->assertOk()
              ->assertJson(['data' => ['id' => $user->id]]);
      }
      
      public function test_guest_receives_401(): void
      {
          $this->getJson('/api/profile')->assertUnauthorized();
      }
      
      // Sanctum with specific abilities
      public function test_user_with_wrong_ability_gets_403(): void
      {
          $user = User::factory()->create();
          Sanctum::actingAs($user, ['view-posts']);
      
          $this->postJson('/api/posts', ['title' => 'Test'])
              ->assertForbidden();
      }
      ```
      
      ## Authorization Testing
      
      ```php
      public function test_user_cannot_delete_others_posts(): void
      {
          $user = User::factory()->create();
          $post = Post::factory()->create(); // different user
      
          $this->actingAs($user)
              ->deleteJson("/api/posts/{$post->id}")
              ->assertForbidden();
      }
      
      public function test_admin_can_delete_any_post(): void
      {
          $admin = User::factory()->admin()->create();
          $post = Post::factory()->create();
      
          $this->actingAs($admin)
              ->deleteJson("/api/posts/{$post->id}")
              ->assertNoContent();
      
          $this->assertDatabaseMissing('posts', ['id' => $post->id]);
      }
      ```
      
      ## Validation Testing
      
      ```php
      public function test_post_requires_title_and_content(): void
      {
          $user = User::factory()->create();
      
          $this->actingAs($user)
              ->postJson('/api/posts', [])
              ->assertUnprocessable()
              ->assertJsonValidationErrors(['title', 'content']);
      }
      ```
      
      ## API Response Structure
      
      ```php
      public function test_returns_paginated_posts(): void
      {
          Post::factory()->count(30)->create();
      
          $this->getJson('/api/posts')
              ->assertOk()
              ->assertJsonStructure([
                  'data' => [['id', 'title', 'content', 'created_at']],
                  'meta' => ['total', 'current_page', 'last_page'],
              ])
              ->assertJsonCount(15, 'data');
      }
      ```
      
      ## Fluent JSON Assertions
      
      For complex API responses, use `AssertableJson`:
      
      ```php
      use Illuminate\Testing\Fluent\AssertableJson;
      
      public function test_user_api_response_shape(): void
      {
          $user = User::factory()->create();
      
          $this->actingAs($user)
              ->getJson('/api/profile')
              ->assertJson(fn (AssertableJson $json) =>
                  $json->has('data', fn ($j) =>
                      $j->where('id', $user->id)
                         ->where('email', $user->email)
                         ->whereType('created_at', 'string')
                         ->etc()
                  )
              );
      }
      ```
      
      ## Database Assertions
      
      ```php
      public function test_creates_record_with_correct_attributes(): void
      {
          $user = User::factory()->create();
      
          $this->actingAs($user)
              ->postJson('/api/posts', ['title' => 'Test', 'body' => 'Content']);
      
          $this->assertDatabaseHas('posts', [
              'title' => 'Test',
              'user_id' => $user->id,
          ]);
      }
      
      public function test_soft_deletes_record(): void
      {
          $post = Post::factory()->create();
      
          $this->actingAs($post->user)
              ->deleteJson("/api/posts/{$post->id}");
      
          $this->assertSoftDeleted('posts', ['id' => $post->id]);
      }
      ```
      
      ## N+1 Query Count Testing
      
      ```php
      public function test_index_avoids_n_plus_one(): void
      {
          Post::factory()->count(10)->create();
      
          $this->expectsDatabaseQueryCount(2); // 1 posts + 1 users (eager loaded)
      
          $this->getJson('/api/posts')->assertOk();
      }
      ```
      
      ## Console / Artisan Command Testing
      
      ```php
      public function test_inspire_command_succeeds(): void
      {
          $this->artisan('inspire')->assertSuccessful();
      }
      
      public function test_command_output(): void
      {
          $this->artisan('greet', ['name' => 'Taylor'])
              ->expectsOutput('Hello, Taylor!')
              ->assertSuccessful();
      }
      
      public function test_interactive_command(): void
      {
          $this->artisan('make:user')
              ->expectsQuestion('What is the name?', 'John')
              ->expectsQuestion('What is the email?', 'john@example.com')
              ->expectsConfirmation('Are you sure?', 'yes')
              ->expectsOutput('User created!')
              ->assertSuccessful();
      }
      
      public function test_scheduled_command_runs_daily(): void
      {
          $events = collect(app(Schedule::class)->events())
              ->filter(fn ($e) => str_contains($e->command, 'backup:run'));
      
          $this->assertCount(1, $events);
          $this->assertSame('0 0 * * *', $events->first()->expression);
      }
      ```
      
      ## Debugging Helpers
      
      ```php
      // Show full exception stack trace instead of HTTP error response
      $this->withoutExceptionHandling()->get('/broken');
      
      // Follow redirects automatically
      $this->followingRedirects()->post('/login', $creds)->assertSee('Dashboard');
      
      // Skip specific middleware for testing
      $this->withoutMiddleware(ThrottleRequests::class)->get('/api/posts');
      ```
      
    • mocking-and-faking.md 5.9 KB
      # Mocking and Faking
      
      Fake facades BEFORE the action that triggers them. Assert AFTER.
      
      ## Queue Faking
      
      ```php
      public function test_dispatches_job_on_post_creation(): void
      {
          Queue::fake();
      
          $user = User::factory()->create();
          $this->actingAs($user)
              ->postJson('/api/posts', ['title' => 'Test', 'body' => 'Content']);
      
          Queue::assertPushed(ProcessPost::class, fn ($job) => $job->post->title === 'Test');
          Queue::assertPushed(ProcessPost::class, 1); // exact count
      }
      ```
      
      ## Event Faking
      
      ```php
      public function test_fires_event_on_publish(): void
      {
          Event::fake([PostPublished::class]);
      
          $post = Post::factory()->create();
          $post->publish();
      
          Event::assertDispatched(PostPublished::class, fn ($e) => $e->post->id === $post->id);
      }
      ```
      
      ## Notification Faking
      
      ```php
      public function test_sends_notification_to_post_author(): void
      {
          Notification::fake();
      
          $post = Post::factory()->create();
          $post->approve();
      
          Notification::assertSentTo($post->user, PostApproved::class);
      }
      ```
      
      ## Mail Faking
      
      ```php
      public function test_sends_welcome_email(): void
      {
          Mail::fake();
      
          $this->postJson('/api/register', [
              'email' => 'new@example.com',
              'password' => 'secret123',
          ]);
      
          Mail::assertSent(WelcomeMail::class, fn ($mail) => $mail->hasTo('new@example.com'));
      }
      ```
      
      ## Storage Faking
      
      ```php
      public function test_uploads_avatar(): void
      {
          Storage::fake('public');
          $user = User::factory()->create();
          $file = UploadedFile::fake()->image('avatar.jpg');
      
          $this->actingAs($user)
              ->postJson('/api/avatar', ['avatar' => $file])
              ->assertOk();
      
          Storage::disk('public')->assertExists("avatars/{$file->hashName()}");
      }
      ```
      
      ## HTTP Faking (External APIs)
      
      ```php
      public function test_fetches_data_from_external_api(): void
      {
          Http::fake([
              'api.example.com/*' => Http::response(['data' => ['id' => 1, 'name' => 'Test']], 200),
          ]);
      
          $service = app(ExternalApiService::class);
          $result = $service->fetchData();
      
          $this->assertSame('Test', $result['name']);
      
          Http::assertSent(fn ($request) =>
              $request->url() === 'https://api.example.com/data'
              && $request->hasHeader('Authorization')
          );
      }
      ```
      
      Use `Http::preventStrayRequests()` after faking to fail on any unfaked URL — catches accidental real HTTP calls:
      
      ```php
      Http::fake(['api.example.com/*' => Http::response(['ok' => true])]);
      Http::preventStrayRequests(); // any other URL throws an exception
      ```
      
      ## Bus Faking (Batches & Chains)
      
      ```php
      public function test_dispatches_batch(): void
      {
          Bus::fake();
      
          $this->postJson('/api/import', ['file' => $file]);
      
          Bus::assertBatched(fn ($batch) => $batch->jobs->count() === 10);
      }
      
      public function test_dispatches_chain(): void
      {
          Bus::fake();
      
          $this->postJson('/api/process');
      
          Bus::assertChained([ValidateJob::class, ProcessJob::class, NotifyJob::class]);
      }
      ```
      
      ## Action Testing with resolve() + swap()
      
      For invokable action classes, resolve from the container so DI works. Use `swap()` to replace dependencies with mocks:
      
      ```php
      public function test_processes_order_and_notifies(): void
      {
          $user = User::factory()->create();
          $order = Order::factory()->for($user)->create();
      
          // Mock dependency action and swap into container
          $calculateTotal = Mockery::mock(CalculateOrderTotalAction::class);
          $calculateTotal->shouldReceive('__invoke')
              ->once()
              ->with($order)
              ->andReturn(10000);
          $this->swap(CalculateOrderTotalAction::class, $calculateTotal);
      
          $notifyAction = Mockery::mock(NotifyOrderCreatedAction::class);
          $notifyAction->shouldReceive('__invoke')->once()->with($order);
          $this->swap(NotifyOrderCreatedAction::class, $notifyAction);
      
          // resolve() pulls from container with mocked dependencies injected
          $result = resolve(ProcessOrderAction::class)($order);
      
          $this->assertSame(10000, $result->total);
      }
      ```
      
      Only mock what you own — for external services (Stripe, etc.), create a service abstraction with a driver pattern and swap the driver config in tests, instead of mocking the SDK directly.
      
      ## Mockery (Service Mocking)
      
      For non-facade services where DI mocking is needed:
      
      ```php
      public function test_sends_notification_to_active_users(): void
      {
          $repository = Mockery::mock(UserRepository::class);
          $repository->shouldReceive('findActive')
              ->once()
              ->andReturn(User::factory()->count(2)->make());
      
          $this->app->instance(UserRepository::class, $repository);
      
          $service = app(NotificationService::class);
          $result = $service->notifyActiveUsers('Important message');
      
          $this->assertSame(2, $result->count());
      }
      ```
      
      Prefer Laravel facade fakes over Mockery when both options exist. Use Mockery for custom services and repository interfaces.
      
      ## Time Travel
      
      For testing time-dependent logic (expiration, scheduling, "created N days ago"):
      
      ```php
      public function test_marks_overdue_orders(): void
      {
          $order = Order::factory()->create();
      
          $this->travel(31)->days();
      
          $this->artisan('orders:mark-overdue')->assertSuccessful();
      
          $this->assertDatabaseHas('orders', [
              'id' => $order->id,
              'status' => 'overdue',
          ]);
      
          $this->travelBack(); // reset to real time
      }
      
      public function test_timestamps_match_frozen_time(): void
      {
          $this->freezeTime();
      
          $user = User::factory()->create();
      
          $this->assertSame(now()->toDateTimeString(), $user->created_at->toDateTimeString());
      }
      
      public function test_subscription_expires(): void
      {
          $user = User::factory()->create();
          $subscription = Subscription::factory()->create([
              'user_id' => $user->id,
              'expires_at' => now()->addDays(30),
          ]);
      
          $this->travelTo(now()->addDays(31));
      
          $this->assertTrue($subscription->fresh()->isExpired());
      }
      ```
      
      Available time units: `seconds()`, `minutes()`, `hours()`, `days()`, `weeks()`, `months()`, `years()`. Always call `$this->travelBack()` or use `$this->freezeTime()` to avoid leaking time state between tests.
      
  • manifest.json 1.2 KB
    {
      "query": "testing-laravel",
      "search_queries": ["testing", "php testing", "php laravel", "laravel testing", "phpunit", "php pest testing"],
      "generated": "2026-02-20",
      "token_count": 1180,
      "sources": [
        { "id": "anthropics/skills/webapp-testing", "installs": 12270, "sha1": "a71271f620ffaaceabe7506b6a5bbf73982e3a8e" },
        { "id": "jeffallan/claude-skills/laravel-specialist", "installs": 2600, "sha1": "1b9e632c4f1bb5c7c03073845f899c8a58054d32" },
        { "id": "wshobson/agents/e2e-testing-patterns", "installs": 3481, "sha1": "3b5def163e542f113ae3f1d047224497e0cc1684" },
        { "id": "jeffallan/claude-skills/php-pro", "installs": 1115, "sha1": "d59aabf0057ec1319dce1e58f7d5fdc2eb3411" },
        { "id": "iserter/laravel-claude-agents/laravel-tdd", "installs": 107, "sha1": "ed4804843ad3a3bc0028022957b8d9201ebb72ef" },
        { "id": "leeovery/claude-laravel/laravel-testing", "installs": 0, "sha1": "manual-reference" },
        { "id": "leeovery/claude-laravel/laravel-quality", "installs": 0, "sha1": "manual-reference" },
        { "id": "fusengine/agents/laravel-testing", "installs": 0, "sha1": "manual-reference" },
        { "id": "pluginagentmarketplace/custom-plugin-php/php-testing", "installs": 0, "sha1": "manual-reference" }
      ]
    }
    
  • SKILL.md 4.2 KB
    ---
    name: testing-laravel
    description: >-
      Writes Laravel tests using PHPUnit. Use when "write tests", "add tests",
      "phpunit", "laravel test", "feature test", "unit test", "mock", "factory",
      or testing controllers, models, services, actions, jobs, artisan commands,
      or API endpoints.
    ---
    
    # Testing Laravel
    
    Use PHPUnit with Laravel's testing helpers. Every test file starts with `declare(strict_types=1)`.
    
    ## Test Classification
    
    - **Feature tests** (`tests/Feature/`): HTTP requests through the full stack — routes, controllers, middleware, validation, database. Use `$this->getJson()`, `$this->postJson()`, etc.
    - **Unit tests** (`tests/Unit/`): Isolated logic — services, actions, value objects, helpers. No HTTP, minimal database.
    
    Default to feature tests for anything touching routes, controllers, or models. Use unit tests for pure logic and action classes.
    
    ## Critical Rules
    
    - `use RefreshDatabase` trait in every test class that touches the database
    - Model factories for all test data — use factories instead of raw `DB::table()` inserts
    - One behavior per test method. Name with `test_` prefix: `test_user_can_update_own_profile`
    - Assert both response status AND side effects (DB state, dispatched jobs, sent notifications)
    - `actingAs($user)` for auth — use this instead of manually setting sessions or tokens
    - `postJson()` / `getJson()` for API endpoints — sets proper Accept headers and returns JSON assertions
    - Fake facades BEFORE the action: `Queue::fake()` then act then `Queue::assertPushed(...)`
    - `assertDatabaseHas` / `assertDatabaseMissing` to verify persistence — use these instead of re-querying
    - Resolve action classes from the container with `resolve()` so DI works; use `swap()` to inject mocks
    - **Tests expose bugs, not the reverse**: If a test uncovers broken or buggy behavior, highlight the issue and propose a fix to the source code. Never adjust the test to match incorrect behavior.
    
    ## PHPUnit Essentials
    
    ```php
    <?php
    
    declare(strict_types=1);
    
    namespace Tests\Feature;
    
    use App\Models\{User, Post};
    use Illuminate\Foundation\Testing\RefreshDatabase;
    use Tests\TestCase;
    
    final class PostTest extends TestCase
    {
        use RefreshDatabase;
    
        public function test_authenticated_user_can_create_post(): void
        {
            $user = User::factory()->create();
    
            $response = $this->actingAs($user)
                ->postJson('/api/posts', ['title' => 'New Post', 'body' => 'Content']);
    
            $response->assertCreated()
                ->assertJson(['data' => ['title' => 'New Post']]);
    
            $this->assertDatabaseHas('posts', [
                'title' => 'New Post',
                'user_id' => $user->id,
            ]);
        }
    }
    ```
    
    Data providers for boundary/validation testing:
    
    ```php
    #[DataProvider('titleLengthProvider')]
    public function test_validates_title_length(string $title, bool $valid): void
    {
        $user = User::factory()->create();
        $response = $this->actingAs($user)
            ->postJson('/api/posts', ['title' => $title, 'body' => 'Content']);
    
        $valid ? $response->assertCreated() : $response->assertUnprocessable();
    }
    
    public static function titleLengthProvider(): array
    {
        return [
            'too short' => ['AB', false],
            'minimum valid' => ['ABC', true],
            'maximum valid' => [str_repeat('A', 255), true],
            'too long' => [str_repeat('A', 256), false],
        ];
    }
    ```
    
    See [feature testing patterns](references/feature-testing.md) for auth, validation, API, console, and DB assertions.
    
    See [mocking and faking](references/mocking-and-faking.md) for facade fakes (Queue, Event, Notification, Mail, Storage, Http), action mocking with `swap()`, and Mockery.
    
    See [factories](references/factories.md) for states, relationships, sequences, and afterCreating hooks.
    
    ## Running Tests
    
    For large test suites, call PHPUnit directly to avoid artisan's memory overhead:
    
    ```bash
    ./vendor/bin/phpunit                              # all tests (direct, lower memory)
    ./vendor/bin/phpunit --filter=PostTest             # by name
    ./vendor/bin/phpunit --processes=auto              # parallel (PHPUnit 11+)
    ./vendor/bin/phpunit --coverage-text --min=80      # with coverage threshold
    
    php artisan test                                   # small suites or quick runs
    php -d memory_limit=1G artisan test                # if artisan needed on large suites
    ```
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related