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)