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 testpublic 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 greenSetup
PHPUnit Configuration
xml
<?xml version="1.0" encoding="UTF-8"?><phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd" bootstrap="vendor/autoload.php" colors="true"> <testsuites> <testsuite name="Unit"> <directory suffix="Test.php">tests/Unit</directory> </testsuite> <testsuite name="Feature"> <directory suffix="Test.php">tests/Feature</directory> </testsuite> </testsuites> <php> <env name="APP_ENV" value="testing"/> <env name="BCRYPT_ROUNDS" value="4"/> <env name="CACHE_STORE" value="array"/> <env name="DB_CONNECTION" value="sqlite"/> <env name="DB_DATABASE" value=":memory:"/> <env name="MAIL_MAILER" value="array"/> <env name="QUEUE_CONNECTION" value="sync"/> <env name="SESSION_DRIVER" value="array"/> </php></phpunit>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.phpclass 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.phpclass 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();
// SequencesUser::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' => '[email protected]', 'password' => 'Password123!', 'password_confirmation' => 'Password123!', ]);
$response->assertCreated(); $response->assertJsonStructure(['data' => ['user', 'token']]); }
public function test_users_can_login(): void { User::factory()->create([ 'email' => '[email protected]', 'password' => Hash::make('Password123!'), ]);
$response = $this->postJson('/api/login', [ 'email' => '[email protected]', 'password' => 'Password123!', ]);
$response->assertOk(); $response->assertJsonStructure(['data' => ['token']]); }
public function test_users_cannot_login_with_wrong_password(): void { User::factory()->create(['email' => '[email protected]']);
$this->postJson('/api/login', [ 'email' => '[email protected]', '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
<?php
use App\Models\Product;use App\Models\User;
uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);
beforeEach(function () { $this->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=80Coverage 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
RefreshDatabasefor 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 patternslaravel-security— Laravel authentication, authorization, and secure codingtdd-workflow— The repo-wide RED -> GREEN -> REFACTOR loopbackend-patterns— General backend API and database patterns


