--- name: laravel-tdd description: Laravel testing strategies with PHPUnit, Pest, model factories, HTTP tests, Sanctum authentication testing, mocking, and coverage. origin: ECC --- # Laravel Testing with TDD Test-driven development for Laravel applications using PHPUnit, Pest, Laravel factories, and testing helpers. ## When to Activate - Writing new Laravel applications or features - Implementing API endpoints with Sanctum or Passport authentication - Testing Eloquent models, relationships, scopes, and accessors - Setting up testing infrastructure for Laravel projects - Writing feature tests for HTTP controllers and form requests - Mocking external services (queues, mail, notifications, HTTP) ## TDD Workflow for Laravel ### Red-Green-Refactor Cycle ```php // Step 1: RED — Write a failing test public function test_a_product_can_be_created(): void { $product = Product::factory()->create(['name' => 'Test Product']); $this->assertDatabaseHas('products', ['name' => 'Test Product']); } // Step 2: GREEN — Write the migration, model, and factory // Step 3: REFACTOR — Improve while keeping tests green ``` ## Setup ### PHPUnit Configuration ```xml tests/Unit tests/Feature ``` ### Base TestCase Setup ```php namespace Tests; use Illuminate\Foundation\Testing\TestCase as BaseTestCase; abstract class TestCase extends BaseTestCase { protected function setUp(): void { parent::setUp(); // Call $this->withoutExceptionHandling() only in tests that // test non-HTTP exceptions; it suppresses assertStatus() etc. } // Helper: Authenticate and return user protected function actingAsUser(): mixed { $user = \App\Models\User::factory()->create(); $this->actingAs($user); return $user; } protected function actingAsAdmin(): mixed { $admin = \App\Models\User::factory()->admin()->create(); $this->actingAs($admin); return $admin; } } ``` ## Model Factories ```php // database/factories/UserFactory.php class UserFactory extends Factory { protected static ?string $password = null; public function definition(): array { return [ 'name' => fake()->name(), 'email' => fake()->unique()->safeEmail(), 'email_verified_at' => now(), 'password' => static::$password ??= Hash::make('password'), 'remember_token' => Str::random(10), 'role' => 'user', ]; } public function admin(): static { return $this->state(fn (array $attributes) => ['role' => 'admin']); } public function unverified(): static { return $this->state(fn (array $attributes) => ['email_verified_at' => null]); } } // database/factories/ProductFactory.php class ProductFactory extends Factory { public function definition(): array { return [ 'name' => fake()->unique()->words(3, true), 'slug' => fn (array $attrs) => Str::slug($attrs['name']), 'description' => fake()->paragraph(), 'price' => fake()->numberBetween(100, 100000), 'stock' => fake()->numberBetween(0, 100), 'is_active' => true, 'user_id' => UserFactory::new(), ]; } public function outOfStock(): static { return $this->state(fn (array $attributes) => ['stock' => 0]); } } ``` ### Using Factories ```php $user = User::factory()->create(); $admin = User::factory()->admin()->create(); $product = Product::factory()->create(['user_id' => $user->id]); $products = Product::factory()->count(10)->create(); $draft = Product::factory()->make(); // Not persisted // With relationships $user = User::factory()->has(Product::factory()->count(3))->create(); // Sequences User::factory()->count(3)->sequence( ['role' => 'admin'], ['role' => 'editor'], ['role' => 'user'], )->create(); ``` ## Model Testing ```php namespace Tests\Unit\Models; use App\Models\User; use App\Models\Product; use Illuminate\Foundation\Testing\RefreshDatabase; use Tests\TestCase; class UserTest extends TestCase { use RefreshDatabase; public function test_it_hides_sensitive_attributes(): void { $user = User::factory()->create(); $this->assertArrayNotHasKey('password', $user->toArray()); } public function test_admin_scope_returns_only_admins(): void { User::factory()->admin()->create(); User::factory()->count(3)->create(); $this->assertCount(1, User::admin()->get()); } } class ProductTest extends TestCase { use RefreshDatabase; public function test_active_scope_filters_correctly(): void { Product::factory()->count(3)->create(['is_active' => true]); Product::factory()->count(2)->create(['is_active' => false]); $this->assertCount(3, Product::active()->get()); } public function test_it_belongs_to_a_user(): void { $user = User::factory()->create(); $product = Product::factory()->create(['user_id' => $user->id]); $this->assertTrue($product->user->is($user)); } } ``` ## Feature / HTTP Testing ```php namespace Tests\Feature\Http\Controllers; use App\Models\Product; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; use Tests\TestCase; class ProductControllerTest extends TestCase { use RefreshDatabase; public function test_guests_are_redirected_to_login(): void { $this->get(route('products.create'))->assertRedirect(route('login')); } public function test_it_stores_a_new_product(): void { $user = User::factory()->create(); $this->actingAs($user); $response = $this->post(route('products.store'), [ 'name' => 'New Product', 'description' => 'Description', 'price' => 2999, 'stock' => 10, ]); $response->assertRedirect(route('products.index')); $this->assertDatabaseHas('products', [ 'name' => 'New Product', 'user_id' => $user->id, ]); } public function test_it_validates_required_fields(): void { $this->actingAs(User::factory()->create()); $this->post(route('products.store'), []) ->assertSessionHasErrors(['name', 'price']); } public function test_users_cannot_modify_others_products(): void { $owner = User::factory()->create(); $attacker = User::factory()->create(); $product = Product::factory()->create(['user_id' => $owner->id]); $this->actingAs($attacker) ->delete(route('products.destroy', $product)) ->assertForbidden(); } } ``` ## JSON API Testing ```php namespace Tests\Feature\Http\Controllers\Api; use App\Models\Product; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; use Tests\TestCase; class ProductApiTest extends TestCase { use RefreshDatabase; public function test_unauthenticated_requests_are_rejected(): void { $this->getJson('/api/products')->assertUnauthorized(); } public function test_it_lists_paginated_products(): void { $user = User::factory()->create(); Product::factory()->count(5)->create(['user_id' => $user->id]); $response = $this->actingAs($user)->getJson('/api/products'); $response->assertOk(); $response->assertJsonCount(5, 'data'); $response->assertJsonStructure([ 'data' => [['id', 'name', 'price']], 'meta' => ['current_page', 'last_page', 'total'], ]); } public function test_it_creates_a_product(): void { $user = User::factory()->create(); $response = $this->actingAs($user)->postJson('/api/products', [ 'name' => 'API Product', 'price' => 4999, ]); $response->assertCreated(); $response->assertJsonPath('data.name', 'API Product'); } public function test_users_cannot_delete_others_products(): void { $owner = User::factory()->create(); $attacker = User::factory()->create(); $product = Product::factory()->create(['user_id' => $owner->id]); $this->actingAs($attacker) ->deleteJson("/api/products/{$product->id}") ->assertForbidden(); } } ``` ## Sanctum API Auth Testing ```php namespace Tests\Feature\Http\Controllers\Api; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Hash; use Tests\TestCase; class AuthControllerTest extends TestCase { use RefreshDatabase; public function test_users_can_register(): void { $response = $this->postJson('/api/register', [ 'name' => 'Test User', 'email' => 'test@example.com', 'password' => 'Password123!', 'password_confirmation' => 'Password123!', ]); $response->assertCreated(); $response->assertJsonStructure(['data' => ['user', 'token']]); } public function test_users_can_login(): void { User::factory()->create([ 'email' => 'test@example.com', 'password' => Hash::make('Password123!'), ]); $response = $this->postJson('/api/login', [ 'email' => 'test@example.com', 'password' => 'Password123!', ]); $response->assertOk(); $response->assertJsonStructure(['data' => ['token']]); } public function test_users_cannot_login_with_wrong_password(): void { User::factory()->create(['email' => 'test@example.com']); $this->postJson('/api/login', [ 'email' => 'test@example.com', 'password' => 'wrong', ])->assertUnprocessable(); } public function test_token_bearer_authenticates_requests(): void { $user = User::factory()->create(); $token = $user->createToken('test')->plainTextToken; $this->withToken($token) ->getJson('/api/user') ->assertOk() ->assertJsonPath('data.email', $user->email); } } ``` ## Mocking and Fakes ### HTTP Fake ```php use Illuminate\Support\Facades\Http; public function test_it_handles_successful_payment(): void { Http::fake([ 'api.stripe.com/*' => Http::response(['id' => 'pi_123', 'status' => 'succeeded'], 200), ]); $result = (new PaymentService())->charge(2999); $this->assertTrue($result->success); } public function test_it_handles_gateway_failure(): void { Http::fake([ 'api.stripe.com/*' => Http::response(['error' => 'card_declined'], 402), ]); $this->expectException(PaymentFailedException::class); (new PaymentService())->charge(2999); } public function test_it_retries_on_timeout(): void { Http::fake([ 'api.stripe.com/*' => Http::sequence() ->pushStatus(408) ->pushStatus(200), ]); $this->assertTrue((new PaymentService())->charge(2999)->success); } ``` ### Mail Fake ```php Mail::fake(); $order->sendConfirmation(); Mail::assertSent(OrderConfirmation::class, function ($mail) use ($order) { return $mail->hasTo($order->user->email); }); ``` ### Notification Fake ```php Notification::fake(); $user->notify(new WelcomeUser()); Notification::assertSentTo($user, WelcomeUser::class); ``` ### Queue Fake ```php Queue::fake(); ProcessImage::dispatch($product); Queue::assertPushed(ProcessImage::class, function ($job) use ($product) { return $job->product->id === $product->id; }); ``` ### Storage Fake ```php Storage::fake('public'); $file = UploadedFile::fake()->image('photo.jpg', 200, 200); $response = $this->actingAs($user)->post('/avatar', [ 'avatar' => $file, ]); $response->assertSessionHasNoErrors(); Storage::disk('public')->assertExists('avatars/' . $file->hashName()); ``` ### Event Fake ```php Event::fake(); $order->markAsShipped(); Event::assertDispatched(OrderShipped::class, function ($event) use ($order) { return $event->order->id === $order->id; }); ``` ## Artisan Command Tests ```php public function test_it_sends_newsletters(): void { Mail::fake(); User::factory()->count(5)->create(['subscribed' => true]); $this->artisan('newsletter:send') ->expectsOutput('Sending newsletter to 5 subscribers...') ->assertExitCode(0); Mail::assertSent(NewsletterMail::class, 5); } public function test_it_handles_no_subscribers(): void { $this->artisan('newsletter:send') ->expectsOutput('No subscribers found.') ->assertExitCode(0); } ``` ## Authorization Tests ```php public function test_users_can_update_own_posts(): void { $user = User::factory()->create(); $post = Post::factory()->create(['user_id' => $user->id]); $this->actingAs($user) ->put(route('posts.update', $post), ['title' => 'Updated']) ->assertRedirect(); } public function test_users_cannot_update_others_posts(): void { $post = Post::factory()->create(); $this->actingAs(User::factory()->create()) ->put(route('posts.update', $post), ['title' => 'Hacked']) ->assertForbidden(); } public function test_gate_before_grants_super_admin_full_access(): void { $super = User::factory()->create(['role' => 'super-admin']); $post = Post::factory()->create(); $this->actingAs($super) ->delete(route('posts.destroy', $post)) ->assertRedirect(); $this->assertSoftDeleted($post); } ``` ## Pest Feature Tests ```php user = User::factory()->create(); $this->actingAs($this->user); }); it('lists products', function () { Product::factory()->count(3)->create(['user_id' => $this->user->id]); $this->get(route('products.index')) ->assertOk() ->assertViewHas('products'); }); it('creates a product with valid data', function () { $this->post(route('products.store'), [ 'name' => 'Test Product', 'price' => 1999, ])->assertRedirect(); $this->assertDatabaseHas('products', ['name' => 'Test Product']); }); it('fails validation without required fields', function () { $this->post(route('products.store'), []) ->assertSessionHasErrors(['name', 'price']); }); it('authorizes updates', function () { $other = User::factory()->create(); $product = Product::factory()->create(['user_id' => $other->id]); $this->put(route('products.update', $product), ['name' => 'Hacked']) ->assertForbidden(); }); ``` ## Coverage ```bash # PHPUnit (use clover output for CI threshold checks) vendor/bin/phpunit --coverage-html coverage --coverage-clover clover.xml # Pest (built-in threshold support) vendor/bin/pest --coverage --min=80 ``` ### Coverage Goals | Component | Target | |-----------|--------| | Models | 95%+ | | Actions/Services | 90%+ | | Form Requests | 90%+ | | Controllers | 85%+ | | Policies | 95%+ | | Overall | 80%+ | ## Testing Best Practices ### DO - Use factories over manual `create()` calls - One logical assertion per test - Descriptive names: `test_guests_cannot_create_products` - Test edge cases and authorization boundaries - Mock external services with `Http::fake()`, `Mail::fake()` - Use `RefreshDatabase` for clean state ### DON'T - Don't test Laravel internals (trust the framework) - Don't make tests dependent on each other - Don't over-mock — mock only service boundaries - Don't test private methods — test through the public interface - Don't couple tests to HTML structure ## Quick Reference | Pattern | Usage | |---------|-------| | `RefreshDatabase` | Reset database between tests | | `$this->actingAs($user)` | Authenticate as user | | `$this->withToken($token)` | Bearer token auth for APIs | | `Model::factory()->create()` | Create model with factory | | `Model::factory()->count(5)->create()` | Create multiple records | | `Http::fake([...])` | Mock HTTP calls | | `Mail::fake()` | Trap sent mail | | `Notification::fake()` | Trap sent notifications | | `Queue::fake()` | Trap queued jobs | | `Event::fake()` | Trap dispatched events | | `Storage::fake('public')` | Trap file operations | | `assertDatabaseHas` | Assert DB row exists | | `assertSoftDeleted` | Assert soft-delete | | `assertSessionHasErrors` | Assert validation errors | | `assertForbidden` | Assert 403 status | ## Related Skills - `laravel-patterns` — Laravel architecture, Eloquent, routing, and API patterns - `laravel-security` — Laravel authentication, authorization, and secure coding - `tdd-workflow` — The repo-wide RED -> GREEN -> REFACTOR loop - `backend-patterns` — General backend API and database patterns