Testing & TDD

Testing Laravel Applications: Tests, Factories, and HTTP Testing

22 min Lesson 10 of 35

Laravel Testing Overview

Laravel provides excellent built-in testing support that makes testing PHP applications enjoyable and productive. Built on top of PHPUnit, Laravel adds convenient testing helpers, database management features, HTTP testing utilities, and a clean API for testing everything from simple units to complete feature workflows. Laravel's testing tools are designed to make testing feel natural and integrated into your development workflow.

Laravel supports two types of tests out of the box: Unit tests for testing small, isolated pieces of functionality, and Feature tests for testing larger pieces of your application including HTTP requests, database operations, and interactions between multiple components. The framework provides everything you need: factories for generating test data, RefreshDatabase for clean test states, HTTP testing helpers for API testing, and authentication helpers for testing protected routes.

Laravel Testing Features:
  • PHPUnit Integration: Built on PHPUnit with additional helpers
  • Database Management: RefreshDatabase, DatabaseTransactions traits
  • HTTP Testing: Test API endpoints and routes easily
  • Factories: Generate fake data for testing
  • Authentication: Easy user impersonation in tests
  • Artisan Commands: Run tests with `php artisan test`

Setting Up Laravel Tests

Test Structure

Laravel organizes tests in two directories:

tests/ ├── Feature/ # Feature tests (test complete features) │ └── UserTest.php └── Unit/ # Unit tests (test individual classes/methods) └── UserServiceTest.php

Creating Tests

# Create a unit test php artisan make:test UserServiceTest --unit # Create a feature test php artisan make:test UserApiTest # Run tests php artisan test # Run with coverage php artisan test --coverage # Run specific test file php artisan test tests/Feature/UserTest.php # Run specific test method php artisan test --filter test_user_can_register

Basic Test Structure

<?php namespace Tests\Feature; use Tests\TestCase; use Illuminate\Foundation\Testing\RefreshDatabase; class UserTest extends TestCase { use RefreshDatabase; public function test_user_can_register() { $response = $this->post('/api/register', [ 'name' => 'John Doe', 'email' => 'john@example.com', 'password' => 'password123', 'password_confirmation' => 'password123' ]); $response->assertStatus(201); $this->assertDatabaseHas('users', [ 'email' => 'john@example.com' ]); } }

Database Testing

RefreshDatabase Trait

The RefreshDatabase trait runs migrations before each test and rolls them back after, ensuring a clean database state:

<?php namespace Tests\Feature; use Tests\TestCase; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; class UserTest extends TestCase { use RefreshDatabase; public function test_user_can_be_created() { $user = User::create([ 'name' => 'John Doe', 'email' => 'john@example.com', 'password' => bcrypt('password123') ]); $this->assertDatabaseHas('users', [ 'email' => 'john@example.com' ]); } public function test_user_can_be_updated() { $user = User::factory()->create(); $user->update(['name' => 'Jane Doe']); $this->assertDatabaseHas('users', [ 'id' => $user->id, 'name' => 'Jane Doe' ]); } public function test_user_can_be_deleted() { $user = User::factory()->create(); $user->delete(); $this->assertDatabaseMissing('users', [ 'id' => $user->id ]); } }

DatabaseTransactions Trait

For faster tests, use DatabaseTransactions to wrap each test in a transaction and rollback:

<?php use Illuminate\Foundation\Testing\DatabaseTransactions; class UserTest extends TestCase { use DatabaseTransactions; // Tests run inside transactions and are rolled back }
RefreshDatabase vs DatabaseTransactions:
  • RefreshDatabase: Slower, but more thorough. Runs migrations, good for testing migrations themselves
  • DatabaseTransactions: Faster, wraps tests in transactions. Cannot test transactions in your code

Database Assertions

// Assert record exists $this->assertDatabaseHas('users', [ 'email' => 'john@example.com', 'name' => 'John Doe' ]); // Assert record doesn't exist $this->assertDatabaseMissing('users', [ 'email' => 'deleted@example.com' ]); // Assert count $this->assertDatabaseCount('users', 10); // Assert soft deleted $this->assertSoftDeleted('users', [ 'id' => $user->id ]); // Assert not soft deleted $this->assertNotSoftDeleted('users', [ 'id' => $user->id ]);

Model Factories

Creating Factories

# Generate factory php artisan make:factory UserFactory --model=User
<?php namespace Database\Factories; use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Support\Str; class UserFactory extends Factory { public function definition() { return [ 'name' => fake()->name(), 'email' => fake()->unique()->safeEmail(), 'email_verified_at' => now(), 'password' => bcrypt('password'), 'remember_token' => Str::random(10), ]; } // Factory states public function unverified() { return $this->state(fn (array $attributes) => [ 'email_verified_at' => null, ]); } public function admin() { return $this->state(fn (array $attributes) => [ 'role' => 'admin', ]); } }

Using Factories

// Create single user $user = User::factory()->create(); // Create multiple users $users = User::factory()->count(10)->create(); // Create with specific attributes $user = User::factory()->create([ 'email' => 'john@example.com', 'name' => 'John Doe' ]); // Use factory states $unverifiedUser = User::factory()->unverified()->create(); $adminUser = User::factory()->admin()->create(); // Chain states $user = User::factory() ->unverified() ->admin() ->create(); // Make without saving to database $user = User::factory()->make();

Relationships in Factories

<?php // Post Factory class PostFactory extends Factory { public function definition() { return [ 'user_id' => User::factory(), 'title' => fake()->sentence(), 'content' => fake()->paragraphs(3, true), 'published_at' => now(), ]; } } // Usage $post = Post::factory()->create(); // Creates user automatically $post = Post::factory() ->for(User::factory()->admin()) ->create(); // Creates post for admin user // Has many relationships $user = User::factory() ->has(Post::factory()->count(5)) ->create(); // Creates user with 5 posts // Alternative syntax $user = User::factory() ->hasPosts(5) ->create(); // Many to many $user = User::factory() ->hasAttached(Role::factory()->count(3)) ->create();

HTTP Testing

Making Requests

// GET request $response = $this->get('/api/users'); // POST request $response = $this->post('/api/users', [ 'name' => 'John Doe', 'email' => 'john@example.com' ]); // PUT request $response = $this->put('/api/users/1', [ 'name' => 'Jane Doe' ]); // PATCH request $response = $this->patch('/api/users/1', [ 'name' => 'Jane Doe' ]); // DELETE request $response = $this->delete('/api/users/1'); // With headers $response = $this->withHeaders([ 'X-Custom-Header' => 'value' ])->get('/api/users'); // JSON request $response = $this->postJson('/api/users', [ 'name' => 'John Doe' ]);

Response Assertions

// Status assertions $response->assertStatus(200); $response->assertOk(); // 200 $response->assertCreated(); // 201 $response->assertNoContent(); // 204 $response->assertNotFound(); // 404 $response->assertForbidden(); // 403 $response->assertUnauthorized(); // 401 // JSON assertions $response->assertJson([ 'name' => 'John Doe', 'email' => 'john@example.com' ]); $response->assertJsonStructure([ 'data' => [ '*' => ['id', 'name', 'email'] ] ]); $response->assertJsonPath('user.name', 'John Doe'); $response->assertJsonCount(10, 'data'); // Header assertions $response->assertHeader('Content-Type', 'application/json'); // Redirect assertions $response->assertRedirect('/dashboard'); // View assertions $response->assertViewIs('users.index'); $response->assertViewHas('users');

Testing JSON APIs

public function test_can_list_users() { User::factory()->count(3)->create(); $response = $this->getJson('/api/users'); $response ->assertStatus(200) ->assertJsonCount(3, 'data') ->assertJsonStructure([ 'data' => [ '*' => ['id', 'name', 'email'] ] ]); } public function test_can_create_user() { $userData = [ 'name' => 'John Doe', 'email' => 'john@example.com', 'password' => 'password123' ]; $response = $this->postJson('/api/users', $userData); $response ->assertStatus(201) ->assertJson([ 'name' => 'John Doe', 'email' => 'john@example.com' ]); $this->assertDatabaseHas('users', [ 'email' => 'john@example.com' ]); } public function test_validation_fails_for_invalid_email() { $response = $this->postJson('/api/users', [ 'name' => 'John Doe', 'email' => 'invalid-email' ]); $response ->assertStatus(422) ->assertJsonValidationErrors(['email']); }

Authentication Testing

Acting as User

public function test_authenticated_user_can_access_dashboard() { $user = User::factory()->create(); $response = $this->actingAs($user) ->get('/dashboard'); $response->assertStatus(200); } public function test_guest_cannot_access_dashboard() { $response = $this->get('/dashboard'); $response->assertRedirect('/login'); } // With specific guard $response = $this->actingAs($user, 'api') ->get('/api/user'); // With Sanctum use Laravel\Sanctum\Sanctum; Sanctum::actingAs($user); $response = $this->getJson('/api/user');

Testing Authorization

public function test_admin_can_delete_users() { $admin = User::factory()->admin()->create(); $user = User::factory()->create(); $response = $this->actingAs($admin) ->delete("/api/users/{$user->id}"); $response->assertStatus(204); $this->assertDatabaseMissing('users', [ 'id' => $user->id ]); } public function test_regular_user_cannot_delete_users() { $user = User::factory()->create(); $otherUser = User::factory()->create(); $response = $this->actingAs($user) ->delete("/api/users/{$otherUser->id}"); $response->assertStatus(403); }

Testing Laravel Features

Testing Events

use Illuminate\Support\Facades\Event; public function test_user_registration_dispatches_event() { Event::fake([UserRegistered::class]); $this->post('/api/register', [ 'name' => 'John Doe', 'email' => 'john@example.com', 'password' => 'password123' ]); Event::assertDispatched(UserRegistered::class, function ($event) { return $event->user->email === 'john@example.com'; }); }

Testing Jobs

use Illuminate\Support\Facades\Queue; public function test_email_job_is_dispatched() { Queue::fake(); $user = User::factory()->create(); $this->post('/api/send-email', [ 'user_id' => $user->id ]); Queue::assertPushed(SendEmailJob::class, function ($job) use ($user) { return $job->user->id === $user->id; }); }

Testing Mail

use Illuminate\Support\Facades\Mail; public function test_welcome_email_is_sent() { Mail::fake(); $user = User::factory()->create(); $this->post('/api/register', [ 'name' => 'John Doe', 'email' => 'john@example.com', 'password' => 'password123' ]); Mail::assertSent(WelcomeEmail::class, function ($mail) { return $mail->hasTo('john@example.com'); }); }

Testing Notifications

use Illuminate\Support\Facades\Notification; public function test_notification_is_sent() { Notification::fake(); $user = User::factory()->create(); $user->notify(new OrderShipped($order)); Notification::assertSentTo($user, OrderShipped::class); }

Testing Storage

use Illuminate\Support\Facades\Storage; use Illuminate\Http\UploadedFile; public function test_file_upload() { Storage::fake('public'); $file = UploadedFile::fake()->image('avatar.jpg'); $response = $this->post('/api/upload', [ 'file' => $file ]); Storage::disk('public')->assertExists('avatars/' . $file->hashName()); }
Practice Exercise:
  1. Create a Task model with title, description, completed status, and user relationship
  2. Create a TaskFactory with appropriate fake data
  3. Write unit tests for Task model methods (markAsCompleted, markAsIncomplete)
  4. Write feature tests for task CRUD API endpoints (create, read, update, delete)
  5. Test that users can only access their own tasks
  6. Test validation rules for task creation (title required, description optional)
  7. Test that TaskCompleted event is dispatched when task is marked as completed
  8. Ensure all tests use factories and RefreshDatabase trait

Best Practices

1. Use Factories for Test Data

// Bad - hard-coded test data $user = User::create([ 'name' => 'John Doe', 'email' => 'john@example.com', 'password' => bcrypt('password') ]); // Good - use factories $user = User::factory()->create();

2. Test One Thing Per Test

// Bad - tests too much public function test_user_crud() { $user = User::factory()->create(); // Test create, update, delete all in one } // Good - separate tests public function test_user_can_be_created() { } public function test_user_can_be_updated() { } public function test_user_can_be_deleted() { }

3. Use Descriptive Test Names

// Good test names public function test_user_can_register_with_valid_data() { } public function test_registration_fails_with_duplicate_email() { } public function test_admin_can_delete_users() { } public function test_guest_cannot_access_dashboard() { }
Common Pitfalls:
  • Forgetting to use RefreshDatabase trait - tests affect each other
  • Not using factories - brittle, hard-to-maintain tests
  • Testing framework code instead of your code
  • Not cleaning up after tests (files, database records)
  • Testing too much in one test - makes debugging harder

Summary

Laravel provides a comprehensive testing toolkit built on PHPUnit that makes testing PHP applications productive and enjoyable. With RefreshDatabase for clean test states, factories for generating test data, HTTP testing helpers for API testing, and convenient assertion methods, Laravel removes the friction from testing. The framework's testing tools integrate seamlessly with your development workflow, encouraging test-driven development and helping you build more reliable applications.

Master these Laravel testing fundamentals - factories, database management, HTTP testing, and authentication - and you'll be well-equipped to write comprehensive test suites that give you confidence in your code. Remember: good tests are fast, isolated, and focused. Use factories liberally, keep tests independent with RefreshDatabase, and test both success and failure scenarios to build robust, well-tested Laravel applications.