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.
Install
npx skills add https://github.com/iliaal/whetstone/tree/master/distillery/generated-skills/testing-laravel
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install iliaal-whetstone@llmmart
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 RefreshDatabasetrait 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 tokenspostJson()/getJson()for API endpoints — sets proper Accept headers and returns JSON assertions- Fake facades BEFORE the action:
Queue::fake()then act thenQueue::assertPushed(...) assertDatabaseHas/assertDatabaseMissingto verify persistence — use these instead of re-querying- Resolve action classes from the container with
resolve()so DI works; useswap()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.
Reviews (0)
No reviews yet.
No comments yet.