Test-Driven Development (TDD) is a software development methodology that emphasizes writing tests before writing the actual code. With Laravel 12's enhanced testing capabilities, implementing TDD has become more intuitive and powerful than ever. In this comprehensive guide, we'll explore how to master TDD in Laravel 12 through practical examples and best practices.
Understanding TDD: The Red-Green-Refactor Cycle
TDD follows a simple three-step cycle:
- π΄ Red: Write a failing test
- π’ Green: Write the minimum code to make the test pass
- π΅ Refactor: Improve the code while keeping tests green
Let's dive into a practical example to see TDD in action.
Setting Up Laravel 12 for TDD
First, ensure you have a fresh Laravel 12 project with proper testing configuration:
# Create new Laravel 12 project composer create-project laravel/laravel tdd-example cd tdd-example # Install additional testing tools composer require --dev pestphp/pest pestphp/pest-plugin-laravel php artisan pest:install Configure Testing Environment
Update your .env.testing file:
APP_ENV=testing DB_CONNECTION=sqlite DB_DATABASE=:memory: CACHE_DRIVER=array QUEUE_CONNECTION=sync SESSION_DRIVER=array Β Practical TDD Example: Building a Task Management System
Let's build a task management system using TDD principles. We'll create a Task model with CRUD operations.
Step 1: π΄ Red - Write the First Failing Test
Create a feature test for task creation:
<?php // tests/Feature/TaskManagementTest.php use App\\Models\\Task; use App\\Models\\User; use Illuminate\\Foundation\\Testing\\RefreshDatabase; uses(RefreshDatabase::class); describe('Task Management', function () { it('can create a new task', function () { // Arrange $user = User::factory()->create(); $taskData = [ 'title' => 'Complete Laravel TDD article', 'description' => 'Write a comprehensive guide on TDD with Laravel 12', 'due_date' => '2024-12-31', 'priority' => 'high', ]; // Act $response = $this->actingAs($user) ->postJson('/api/tasks', $taskData); // Assert $response->assertStatus(201) ->assertJsonStructure([ 'data' => [ 'id', 'title', 'description', 'due_date', 'priority', 'status', 'user_id', 'created_at', 'updated_at', ] ]); $this->assertDatabaseHas('tasks', [ 'title' => $taskData['title'], 'user_id' => $user->id, ]); }); }); Run the test - it should fail:
php artisan test --filter="can create a new task" Step 2: π’ Green - Make the Test Pass
Now let's create the minimum code to make this test pass:
Create the Migration
php artisan make:migration create_tasks_table <?php // database/migrations/xxxx_create_tasks_table.php use Illuminate\\Database\\Migrations\\Migration; use Illuminate\\Database\\Schema\\Blueprint; use Illuminate\\Support\\Facades\\Schema; return new class extends Migration { public function up(): void { Schema::create('tasks', function (Blueprint $table) { $table->id(); $table->string('title'); $table->text('description')->nullable(); $table->date('due_date')->nullable(); $table->enum('priority', ['low', 'medium', 'high'])->default('medium'); $table->enum('status', ['pending', 'in_progress', 'completed'])->default('pending'); $table->foreignId('user_id')->constrained()->onDelete('cascade'); $table->timestamps(); }); } public function down(): void { Schema::dropIfExists('tasks'); } }; Create the Model
php artisan make:model Task <?php // app/Models/Task.php namespace App\\Models; use Illuminate\\Database\\Eloquent\\Factories\\HasFactory; use Illuminate\\Database\\Eloquent\\Model; use Illuminate\\Database\\Eloquent\\Relations\\BelongsTo; class Task extends Model { use HasFactory; protected $fillable = [ 'title', 'description', 'due_date', 'priority', 'status', 'user_id', ]; protected $casts = [ 'due_date' => 'date', ]; public function user(): BelongsTo { return $this->belongsTo(User::class); } } Create the Controller
php artisan make:controller Api/TaskController --api <?php // app/Http/Controllers/Api/TaskController.php namespace App\\Http\\Controllers\\Api; use App\\Http\\Controllers\\Controller; use App\\Http\\Requests\\StoreTaskRequest; use App\\Http\\Resources\\TaskResource; use App\\Models\\Task; use Illuminate\\Http\\JsonResponse; class TaskController extends Controller { public function store(StoreTaskRequest $request): JsonResponse { $task = Task::create([ ...$request->validated(), 'user_id' => $request->user()->id, ]); return response()->json([ 'data' => new TaskResource($task) ], 201); } } Create the Request
php artisan make:request StoreTaskRequest <?php // app/Http/Requests/StoreTaskRequest.php namespace App\\Http\\Requests; use Illuminate\\Foundation\\Http\\FormRequest; class StoreTaskRequest extends FormRequest { public function authorize(): bool { return true; } public function rules(): array { return [ 'title' => 'required|string|max:255', 'description' => 'nullable|string', 'due_date' => 'nullable|date|after:today', 'priority' => 'in:low,medium,high', ]; } } Create the Resource
php artisan make:resource TaskResource <?php // app/Http/Resources/TaskResource.php namespace App\\Http\\Resources; use Illuminate\\Http\\Request; use Illuminate\\Http\\Resources\\Json\\JsonResource; class TaskResource extends JsonResource { public function toArray(Request $request): array { return [ 'id' => $this->id, 'title' => $this->title, 'description' => $this->description, 'due_date' => $this->due_date?->format('Y-m-d'), 'priority' => $this->priority, 'status' => $this->status, 'user_id' => $this->user_id, 'created_at' => $this->created_at, 'updated_at' => $this->updated_at, ]; } } Add Routes
<?php // routes/api.php use App\\Http\\Controllers\\Api\\TaskController; use Illuminate\\Support\\Facades\\Route; Route::middleware('auth:sanctum')->group(function () { Route::apiResource('tasks', TaskController::class); }); Create Factory
php artisan make:factory TaskFactory <?php // database/factories/TaskFactory.php namespace Database\\Factories; use App\\Models\\User; use Illuminate\\Database\\Eloquent\\Factories\\Factory; class TaskFactory extends Factory { public function definition(): array { return [ 'title' => $this->faker->sentence(4), 'description' => $this->faker->paragraph(), 'due_date' => $this->faker->dateTimeBetween('now', '+1 month'), 'priority' => $this->faker->randomElement(['low', 'medium', 'high']), 'status' => $this->faker->randomElement(['pending', 'in_progress', 'completed']), 'user_id' => User::factory(), ]; } } Run the migration and test:
php artisan migrate php artisan test --filter="can create a new task" The test should now pass! π’
Step 3: π΅ Refactor - Improve the Code
Now let's add more tests and refactor our code for better structure.
Add More Test Cases
<?php // tests/Feature/TaskManagementTest.php describe('Task Management', function () { beforeEach(function () { $this->user = User::factory()->create(); }); it('can create a new task', function () { // Previous test code... }); it('validates required fields when creating a task', function () { $response = $this->actingAs($this->user) ->postJson('/api/tasks', []); $response->assertStatus(422) ->assertJsonValidationErrors(['title']); }); it('can list user tasks', function () { Task::factory(3)->create(['user_id' => $this->user->id]); Task::factory(2)->create(); // Other user's tasks $response = $this->actingAs($this->user) ->getJson('/api/tasks'); $response->assertStatus(200) ->assertJsonCount(3, 'data'); }); it('can show a specific task', function () { $task = Task::factory()->create(['user_id' => $this->user->id]); $response = $this->actingAs($this->user) ->getJson("/api/tasks/{$task->id}"); $response->assertStatus(200) ->assertJson([ 'data' => [ 'id' => $task->id, 'title' => $task->title, ] ]); }); it('can update a task', function () { $task = Task::factory()->create(['user_id' => $this->user->id]); $updateData = [ 'title' => 'Updated Task Title', 'status' => 'completed', ]; $response = $this->actingAs($this->user) ->putJson("/api/tasks/{$task->id}", $updateData); $response->assertStatus(200); $this->assertDatabaseHas('tasks', [ 'id' => $task->id, 'title' => 'Updated Task Title', 'status' => 'completed', ]); }); it('can delete a task', function () { $task = Task::factory()->create(['user_id' => $this->user->id]); $response = $this->actingAs($this->user) ->deleteJson("/api/tasks/{$task->id}"); $response->assertStatus(204); $this->assertDatabaseMissing('tasks', ['id' => $task->id]); }); it('cannot access other users tasks', function () { $otherUser = User::factory()->create(); $task = Task::factory()->create(['user_id' => $otherUser->id]); $response = $this->actingAs($this->user) ->getJson("/api/tasks/{$task->id}"); $response->assertStatus(404); }); }); Complete the Controller
<?php // app/Http/Controllers/Api/TaskController.php namespace App\\Http\\Controllers\\Api; use App\\Http\\Controllers\\Controller; use App\\Http\\Requests\\StoreTaskRequest; use App\\Http\\Requests\\UpdateTaskRequest; use App\\Http\\Resources\\TaskResource; use App\\Models\\Task; use Illuminate\\Http\\JsonResponse; use Illuminate\\Http\\Resources\\Json\\AnonymousResourceCollection; class TaskController extends Controller { public function index(): AnonymousResourceCollection { $tasks = auth()->user()->tasks()->latest()->get(); return TaskResource::collection($tasks); } public function store(StoreTaskRequest $request): JsonResponse { $task = Task::create([ ...$request->validated(), 'user_id' => $request->user()->id, ]); return response()->json([ 'data' => new TaskResource($task) ], 201); } public function show(Task $task): JsonResponse { $this->authorize('view', $task); return response()->json([ 'data' => new TaskResource($task) ]); } public function update(UpdateTaskRequest $request, Task $task): JsonResponse { $this->authorize('update', $task); $task->update($request->validated()); return response()->json([ 'data' => new TaskResource($task) ]); } public function destroy(Task $task): JsonResponse { $this->authorize('delete', $task); $task->delete(); return response()->json(null, 204); } } Add Authorization Policy
php artisan make:policy TaskPolicy --model=Task <?php // app/Policies/TaskPolicy.php namespace App\\Policies; use App\\Models\\Task; use App\\Models\\User; class TaskPolicy { public function view(User $user, Task $task): bool { return $user->id === $task->user_id; } public function update(User $user, Task $task): bool { return $user->id === $task->user_id; } public function delete(User $user, Task $task): bool { return $user->id === $task->user_id; } } Update User Model
<?php // app/Models/User.php use App\\Models\\Task; use Illuminate\\Database\\Eloquent\\Relations\\HasMany; class User extends Authenticatable { // ... existing code public function tasks(): HasMany { return $this->hasMany(Task::class); } } Β Advanced TDD Techniques in Laravel 12
1- Testing with Databases
Use different database strategies for different test types:
<?php // tests/Unit/TaskTest.php use App\\Models\\Task; use App\\Models\\User; describe('Task Model', function () { it('belongs to a user', function () { $user = User::factory()->create(); $task = Task::factory()->create(['user_id' => $user->id]); expect($task->user)->toBeInstanceOf(User::class); expect($task->user->id)->toBe($user->id); }); it('has correct fillable attributes', function () { $task = new Task(); expect($task->getFillable())->toContain('title', 'description', 'due_date', 'priority', 'status', 'user_id'); }); it('casts due_date to date', function () { $task = Task::factory()->create(['due_date' => '2024-12-31']); expect($task->due_date)->toBeInstanceOf(\\Illuminate\\Support\\Carbon::class); }); }); 2- Mocking External Services
<?php // tests/Feature/TaskNotificationTest.php use App\\Models\\Task; use App\\Models\\User; use App\\Notifications\\TaskDueNotification; use Illuminate\\Support\\Facades\\Notification; describe('Task Notifications', function () { it('sends notification when task is due', function () { Notification::fake(); $user = User::factory()->create(); $task = Task::factory()->create([ 'user_id' => $user->id, 'due_date' => now()->addDay(), ]); // Trigger the notification logic $task->sendDueNotification(); Notification::assertSentTo($user, TaskDueNotification::class); }); }); 3- Testing API Responses
<?php // tests/Feature/TaskApiTest.php describe('Task API', function () { it('returns paginated tasks', function () { $user = User::factory()->create(); Task::factory(25)->create(['user_id' => $user->id]); $response = $this->actingAs($user) ->getJson('/api/tasks?page=1&per_page=10'); $response->assertStatus(200) ->assertJsonStructure([ 'data' => [ '*' => [ 'id', 'title', 'description', 'due_date', 'priority', 'status', ] ], 'links', 'meta' ]); }); it('filters tasks by status', function () { $user = User::factory()->create(); Task::factory(3)->create(['user_id' => $user->id, 'status' => 'completed']); Task::factory(2)->create(['user_id' => $user->id, 'status' => 'pending']); $response = $this->actingAs($user) ->getJson('/api/tasks?status=completed'); $response->assertStatus(200) ->assertJsonCount(3, 'data'); }); }); TDD Best Practices for Laravel 12
1- Test Structure and Organization
tests/ βββ Feature/ # Integration tests β βββ TaskManagementTest.php β βββ UserAuthenticationTest.php β βββ TaskApiTest.php βββ Unit/ # Unit tests β βββ Models/ β β βββ TaskTest.php β β βββ UserTest.php β βββ Services/ β βββ TaskServiceTest.php βββ Pest.php # Pest configuration 2- Use Descriptive Test Names
// β Bad it('tests task creation', function () { // ... }); // β
Good it('creates a task with valid data and assigns it to the authenticated user', function () { // ... }); 3- Follow the AAA Pattern
it('updates task status when marked as completed', function () { // Arrange $user = User::factory()->create(); $task = Task::factory()->create(['user_id' => $user->id, 'status' => 'pending']); // Act $response = $this->actingAs($user) ->putJson("/api/tasks/{$task->id}", ['status' => 'completed']); // Assert $response->assertStatus(200); expect($task->fresh()->status)->toBe('completed'); }); 4- Use Factories and Seeders Effectively
<?php // database/factories/TaskFactory.php class TaskFactory extends Factory { public function definition(): array { return [ 'title' => $this->faker->sentence(4), 'description' => $this->faker->paragraph(), 'due_date' => $this->faker->dateTimeBetween('now', '+1 month'), 'priority' => $this->faker->randomElement(['low', 'medium', 'high']), 'status' => 'pending', 'user_id' => User::factory(), ]; } public function completed(): static { return $this->state(fn (array $attributes) => [ 'status' => 'completed', ]); } public function highPriority(): static { return $this->state(fn (array $attributes) => [ 'priority' => 'high', ]); } public function overdue(): static { return $this->state(fn (array $attributes) => [ 'due_date' => $this->faker->dateTimeBetween('-1 month', '-1 day'), ]); } } 5- Test Edge Cases
describe('Task Validation', function () { it('rejects tasks with past due dates', function () { $user = User::factory()->create(); $response = $this->actingAs($user) ->postJson('/api/tasks', [ 'title' => 'Test Task', 'due_date' => '2020-01-01', ]); $response->assertStatus(422) ->assertJsonValidationErrors(['due_date']); }); it('handles extremely long task titles gracefully', function () { $user = User::factory()->create(); $response = $this->actingAs($user) ->postJson('/api/tasks', [ 'title' => str_repeat('a', 300), ]); $response->assertStatus(422) ->assertJsonValidationErrors(['title']); }); }); Β Continuous Integration with TDD
GitHub Actions Configuration
# .github/workflows/tests.yml name: Tests on: push: branches: [ main, develop ] pull_request: branches: [ main ] jobs: test: runs-on: ubuntu-latest services: mysql: image: mysql:8.0 env: MYSQL_ROOT_PASSWORD: password MYSQL_DATABASE: testing ports: - 3306:3306 options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3 steps: - uses: actions/checkout@v3 - name: Setup PHP uses: shivammathur/setup-php@v2 with: php-version: '8.2' extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv coverage: xdebug - name: Install dependencies run: composer install --no-progress --prefer-dist --optimize-autoloader - name: Copy environment file run: cp .env.example .env - name: Generate application key run: php artisan key:generate - name: Run migrations run: php artisan migrate --env=testing - name: Execute tests run: php artisan test --coverage --min=80 Measuring TDD Success
Code Coverage
# Generate coverage report php artisan test --coverage --min=80 # Generate HTML coverage report php artisan test --coverage-html coverage-report Β Test Metrics
Track these metrics to measure TDD effectiveness:
- Test Coverage: Aim for 80%+ coverage
- Test Execution Time: Keep tests fast (<30 seconds)
- Test Reliability: Tests should be deterministic
- Bug Detection Rate: TDD should catch bugs early
Β Common TDD Pitfalls and Solutions
1- Writing Tests After Code
- β Problem: Writing tests after implementation defeats the purpose
- β Solution: Always write the failing test first
2- Testing Implementation Details
- β Problem: Testing how something works instead of what it does
- β Solution: Focus on behavior and outcomes
- Overly Complex Tests
- β Problem: Tests that are hard to understand and maintain
- β Solution: Keep tests simple and focused on one behavior
4- Not Refactoring
- β Problem: Skipping the refactor step leads to technical debt
- β Solution: Always refactor after making tests pass
Conclusion
Test-Driven Development with Laravel 12 provides a robust foundation for building reliable, maintainable applications. The key benefits include:
- Higher Code Quality: TDD forces you to think about design upfront
- Better Test Coverage: Tests are written as part of development
- Faster Debugging: Tests help isolate issues quickly
- Confident Refactoring: Comprehensive tests enable safe code changes
- Living Documentation: Tests serve as executable specifications
Remember the TDD mantra: Red, Green, Refactor. Start small, write failing tests, make them pass with minimal code, then refactor for quality.
The investment in TDD pays dividends in reduced bugs, easier maintenance, and increased developer confidence. Start applying TDD to your next Laravel 12 project and experience the difference !
Top comments (0)