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)