DEV Community

A0mineTV
A0mineTV

Posted on

Mastering Test-Driven Development (TDD) with Laravel 12: A Complete Guide

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:

  1. πŸ”΄ Red: Write a failing test
  2. 🟒 Green: Write the minimum code to make the test pass
  3. πŸ”΅ 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 
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

Β 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, ]); }); }); 
Enter fullscreen mode Exit fullscreen mode

Run the test - it should fail:

php artisan test --filter="can create a new task" 
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode
<?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'); } }; 
Enter fullscreen mode Exit fullscreen mode

Create the Model

php artisan make:model Task 
Enter fullscreen mode Exit fullscreen mode
<?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); } } 
Enter fullscreen mode Exit fullscreen mode

Create the Controller

php artisan make:controller Api/TaskController --api 
Enter fullscreen mode Exit fullscreen mode
<?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); } } 
Enter fullscreen mode Exit fullscreen mode

Create the Request

php artisan make:request StoreTaskRequest 
Enter fullscreen mode Exit fullscreen mode
<?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', ]; } } 
Enter fullscreen mode Exit fullscreen mode

Create the Resource

php artisan make:resource TaskResource 
Enter fullscreen mode Exit fullscreen mode
<?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, ]; } } 
Enter fullscreen mode Exit fullscreen mode

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); }); 
Enter fullscreen mode Exit fullscreen mode

Create Factory

php artisan make:factory TaskFactory 
Enter fullscreen mode Exit fullscreen mode
<?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(), ]; } } 
Enter fullscreen mode Exit fullscreen mode

Run the migration and test:

php artisan migrate php artisan test --filter="can create a new task" 
Enter fullscreen mode Exit fullscreen mode

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); }); }); 
Enter fullscreen mode Exit fullscreen mode

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); } } 
Enter fullscreen mode Exit fullscreen mode

Add Authorization Policy

php artisan make:policy TaskPolicy --model=Task 
Enter fullscreen mode Exit fullscreen mode
<?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; } } 
Enter fullscreen mode Exit fullscreen mode

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); } } 
Enter fullscreen mode Exit fullscreen mode

Β 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); }); }); 
Enter fullscreen mode Exit fullscreen mode

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); }); }); 
Enter fullscreen mode Exit fullscreen mode

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'); }); }); 
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

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 () { // ... }); 
Enter fullscreen mode Exit fullscreen mode

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'); }); 
Enter fullscreen mode Exit fullscreen mode

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'), ]); } } 
Enter fullscreen mode Exit fullscreen mode

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']); }); }); 
Enter fullscreen mode Exit fullscreen mode

Β 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 
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

Β 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
  1. 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)