Complete Guide to Laravel Testing

A comprehensive reference for understanding and implementing testing in Laravel with PHPUnit, feature tests, unit tests, and testing best practices

 

Table of Contents

1. Introduction to Laravel Testing

Laravel provides excellent testing capabilities out of the box using PHPUnit. Testing ensures your application works as expected and helps prevent bugs when making changes.

Laravel Testing allows you to:

  • Test individual components in isolation (Unit Tests)
  • Test complete application workflows (Feature Tests)
  • Test HTTP requests and responses
  • Test database operations
  • Mock external services

2. Setup and Configuration

Testing Environment Configuration

Create a .env.testing file in your project root:

# .env.testing
APP_ENV=testing
DB_CONNECTION=sqlite
DB_DATABASE=:memory:
CACHE_DRIVER=array
SESSION_DRIVER=array
QUEUE_CONNECTION=sync

Running Tests

# Run all tests
php artisan test

# Run specific test file
php artisan test tests/Feature/PostTest.php

# Run tests with coverage
php artisan test --coverage

# Run specific test method
php artisan test --filter test_user_can_create_post

3. Unit Tests

Unit tests focus on testing individual classes and methods in isolation.

Creating Unit Tests

php artisan make:test UserServiceTest --unit

Basic Unit Test Example

<!--?php namespace Tests\Unit; use App\Models\User; use App\Services\UserService; use Tests\TestCase; class UserServiceTest extends TestCase { public function test_can_format_user_full_name() { $user = new User([ 'first_name' => 'John',<br ?--> 'last_name' => 'Doe'
]);

$service = new UserService();
$fullName = $service->formatFullName($user);

$this->assertEquals('John Doe', $fullName);
}

public function test_validates_email_format()
{
$service = new UserService();

$this->assertTrue($service->isValidEmail('[email protected]'));
$this->assertFalse($service->isValidEmail('invalid-email'));
}
}

Testing Model Methods

<!--?php namespace Tests\Unit; use App\Models\Post; use Illuminate\Foundation\Testing\RefreshDatabase; use Tests\TestCase; class PostModelTest extends TestCase { use RefreshDatabase; public function test_post_slug_is_generated_from_title() { $post = new Post(['title' => 'This is a Test Title']);</p>
<p>        $this->assertEquals('this-is-a-test-title', $post->generateSlug());<br ?--> }

public function test_post_excerpt_truncates_content()
{
$content = str_repeat('This is a long content. ', 20);
$post = new Post(['content' => $content]);

$excerpt = $post->getExcerpt(50);

$this->assertLessThanOrEqual(50, strlen($excerpt));
$this->assertStringEndsWith('...', $excerpt);
}
}

4. Feature Tests

Feature tests test complete application workflows from HTTP requests to responses.

Creating Feature Tests

php artisan make:test PostControllerTest

Basic Feature Test Example

<!--?php namespace Tests\Feature; use App\Models\Post; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; use Tests\TestCase; class PostControllerTest extends TestCase { use RefreshDatabase; public function test_can_view_posts_index() { $posts = Post::factory()->count(3)->create(['is_published' => true]);</p>
<p>        $response = $this->get('/posts');</p>
<p>        $response->assertStatus(200);<br ?--> $response->assertViewIs('posts.index');
$response->assertSeeText($posts->first()->title);
}

public function test_can_view_single_post()
{
$post = Post::factory()->create([
'title' => 'Test Post Title',
'is_published' => true
]);

$response = $this->get("/posts/{$post->slug}");

$response->assertStatus(200);
$response->assertSeeText('Test Post Title');
$response->assertSeeText($post->content);
}

public function test_authenticated_user_can_create_post()
{
$user = User::factory()->create();

$postData = [
'title' => 'New Test Post',
'content' => 'This is the content of the test post.',
'is_published' => true
];

$response = $this->actingAs($user)->post('/posts', $postData);

$response->assertRedirect();
$this->assertDatabaseHas('posts', [
'title' => 'New Test Post',
'user_id' => $user->id
]);
}
}

Testing Form Validation

<!--?php namespace Tests\Feature; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; use Tests\TestCase; class PostValidationTest extends TestCase { use RefreshDatabase; public function test_title_is_required() { $user = User::factory()->create();</p>
<p>        $response = $this->actingAs($user)->post('/posts', [<br ?--> 'content' => 'Post content without title'
]);

$response->assertSessionHasErrors(['title']);
}

public function test_content_is_required()
{
$user = User::factory()->create();

$response = $this->actingAs($user)->post('/posts', [
'title' => 'Post Title'
]);

$response->assertSessionHasErrors(['content']);
}

public function test_valid_post_data_passes_validation()
{
$user = User::factory()->create();

$response = $this->actingAs($user)->post('/posts', [
'title' => 'Valid Post Title',
'content' => 'Valid post content',
'is_published' => true
]);

$response->assertSessionHasNoErrors();
}
}

5. Database Testing

Database Migrations and Factories

<!--?php namespace Tests\Feature; use App\Models\User; use App\Models\Post; use Illuminate\Foundation\Testing\RefreshDatabase; use Tests\TestCase; class DatabaseTest extends TestCase { use RefreshDatabase; public function test_factory_creates_user() { $user = User::factory()->create([<br ?--> 'email' => '[email protected]'
]);

$this->assertDatabaseHas('users', [
'email' => '[email protected]'
]);

$this->assertInstanceOf(User::class, $user);
}

public function test_can_create_multiple_records()
{
User::factory()->count(5)->create();

$this->assertDatabaseCount('users', 5);
}

public function test_relationships_work()
{
$user = User::factory()->create();
$post = Post::factory()->create(['user_id' => $user->id]);

$this->assertEquals($user->id, $post->user->id);
$this->assertTrue($user->posts->contains($post));
}
}

Testing Database Operations

<!--?php namespace Tests\Feature; use App\Models\Post; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; use Tests\TestCase; class DatabaseOperationsTest extends TestCase { use RefreshDatabase; public function test_can_create_post() { $user = User::factory()->create();</p>
<p>        $post = Post::create([<br ?--> 'title' => 'Test Post',
'content' => 'Test content',
'user_id' => $user->id
]);

$this->assertDatabaseHas('posts', [
'title' => 'Test Post',
'user_id' => $user->id
]);
}

public function test_can_update_post()
{
$post = Post::factory()->create(['title' => 'Original Title']);

$post->update(['title' => 'Updated Title']);

$this->assertDatabaseHas('posts', [
'id' => $post->id,
'title' => 'Updated Title'
]);
}

public function test_can_delete_post()
{
$post = Post::factory()->create();
$postId = $post->id;

$post->delete();

$this->assertDatabaseMissing('posts', ['id' => $postId]);
}
}

6. HTTP Testing

Testing HTTP Responses

<!--?php namespace Tests\Feature; use App\Models\Post; use Illuminate\Foundation\Testing\RefreshDatabase; use Tests\TestCase; class HttpResponseTest extends TestCase { use RefreshDatabase; public function test_homepage_returns_successful_response() { $response = $this->get('/');</p>
<p>        $response->assertStatus(200);<br ?--> $response->assertOk();
}

public function test_nonexistent_route_returns_404()
{
$response = $this->get('/nonexistent-route');

$response->assertNotFound();
}

public function test_post_show_returns_correct_data()
{
$post = Post::factory()->create([
'title' => 'Test Post',
'content' => 'Test content'
]);

$response = $this->get("/posts/{$post->slug}");

$response->assertOk();
$response->assertSeeText('Test Post');
$response->assertSeeText('Test content');
}

public function test_redirect_works_correctly()
{
$response = $this->get('/old-url');

$response->assertRedirect('/new-url');
}
}

Testing Form Submissions

<!--?php namespace Tests\Feature; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; use Tests\TestCase; class FormSubmissionTest extends TestCase { use RefreshDatabase; public function test_contact_form_submission() { $formData = [ 'name' => 'John Doe',<br ?--> 'email' => '[email protected]',
'message' => 'Hello there!'
];

$response = $this->post('/contact', $formData);

$response->assertRedirect();
$response->assertSessionHas('success', 'Message sent successfully');
}

public function test_profile_update()
{
$user = User::factory()->create();

$updateData = [
'name' => 'Updated Name',
'email' => '[email protected]'
];

$response = $this->actingAs($user)->put('/profile', $updateData);

$response->assertRedirect();
$this->assertDatabaseHas('users', [
'id' => $user->id,
'name' => 'Updated Name',
'email' => '[email protected]'
]);
}
}

7. Authentication Testing

Testing Login and Registration

<!--?php namespace Tests\Feature; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Hash; use Tests\TestCase; class AuthenticationTest extends TestCase { use RefreshDatabase; public function test_user_can_register() { $userData = [ 'name' => 'John Doe',<br ?--> 'email' => '[email protected]',
'password' => 'password',
'password_confirmation' => 'password'
];

$response = $this->post('/register', $userData);

$response->assertRedirect('/dashboard');
$this->assertDatabaseHas('users', [
'name' => 'John Doe',
'email' => '[email protected]'
]);
$this->assertAuthenticated();
}

public function test_user_can_login()
{
$user = User::factory()->create([
'email' => '[email protected]',
'password' => Hash::make('password')
]);

$response = $this->post('/login', [
'email' => '[email protected]',
'password' => 'password'
]);

$response->assertRedirect('/dashboard');
$this->assertAuthenticatedAs($user);
}

public function test_user_cannot_login_with_invalid_credentials()
{
$user = User::factory()->create();

$response = $this->post('/login', [
'email' => $user->email,
'password' => 'wrong-password'
]);

$response->assertSessionHasErrors();
$this->assertGuest();
}

public function test_user_can_logout()
{
$user = User::factory()->create();

$response = $this->actingAs($user)->post('/logout');

$response->assertRedirect('/');
$this->assertGuest();
}
}

Testing Authorization

<!--?php namespace Tests\Feature; use App\Models\Post; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; use Tests\TestCase; class AuthorizationTest extends TestCase { use RefreshDatabase; public function test_guest_cannot_create_posts() { $response = $this->get('/posts/create');</p>
<p>        $response->assertRedirect('/login');<br ?--> }

public function test_user_can_only_edit_own_posts()
{
$user1 = User::factory()->create();
$user2 = User::factory()->create();
$post = Post::factory()->create(['user_id' => $user1->id]);

// User can edit own post
$response = $this->actingAs($user1)->get("/posts/{$post->id}/edit");
$response->assertOk();

// User cannot edit other's post
$response = $this->actingAs($user2)->get("/posts/{$post->id}/edit");
$response->assertForbidden();
}

public function test_admin_can_access_admin_routes()
{
$admin = User::factory()->create(['role' => 'admin']);
$user = User::factory()->create(['role' => 'user']);

// Admin can access
$response = $this->actingAs($admin)->get('/admin/dashboard');
$response->assertOk();

// Regular user cannot access
$response = $this->actingAs($user)->get('/admin/dashboard');
$response->assertForbidden();
}
}

8. API Testing

Testing JSON APIs

<!--?php namespace Tests\Feature; use App\Models\Post; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; use Tests\TestCase; class ApiTest extends TestCase { use RefreshDatabase; public function test_can_get_all_posts_via_api() { $posts = Post::factory()->count(3)->create();</p>
<p>        $response = $this->getJson('/api/posts');</p>
<p>        $response->assertOk()<br ?--> ->assertJsonCount(3, 'data')
->assertJsonStructure([
'data' => [
'*' => ['id', 'title', 'content', 'created_at']
]
]);
}

public function test_can_create_post_via_api()
{
$user = User::factory()->create();

$postData = [
'title' => 'API Created Post',
'content' => 'This post was created via API'
];

$response = $this->actingAs($user, 'sanctum')
->postJson('/api/posts', $postData);

$response->assertCreated()
->assertJson([
'title' => 'API Created Post'
]);

$this->assertDatabaseHas('posts', $postData);
}

public function test_api_validation_returns_422()
{
$user = User::factory()->create();

$response = $this->actingAs($user, 'sanctum')
->postJson('/api/posts', []);

$response->assertStatus(422);
$response->assertJsonValidationErrors(['title', 'content']);
}

public function test_api_requires_authentication()
{
$response = $this->postJson('/api/posts', [
'title' => 'Test Post',
'content' => 'Test content'
]);

$response->assertUnauthorized();
}
}

9. Mocking Basics

Using Laravel Fakes

<!--?php namespace Tests\Feature; use App\Models\User; use App\Notifications\WelcomeNotification; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Mail; use Illuminate\Support\Facades\Notification; use Illuminate\Support\Facades\Storage; use Tests\TestCase; class FakesTest extends TestCase { use RefreshDatabase; public function test_notification_is_sent() { Notification::fake(); $user = User::factory()->create();</p>
<p>        // Trigger notification<br ?--> $user->notify(new WelcomeNotification());

// Assert notification was sent
Notification::assertSentTo($user, WelcomeNotification::class);
}

public function test_email_is_sent()
{
Mail::fake();

$response = $this->post('/contact', [
'name' => 'John Doe',
'email' => '[email protected]',
'message' => 'Hello!'
]);

$response->assertOk();
Mail::assertSent(\App\Mail\ContactFormMail::class);
}

public function test_file_upload_works()
{
Storage::fake('public');
$user = User::factory()->create();

$response = $this->actingAs($user)->post('/upload', [
'file' => \Illuminate\Http\UploadedFile::fake()->image('avatar.jpg')
]);

$response->assertOk();
Storage::disk('public')->assertExists('avatars/avatar.jpg');
}
}

Simple Mocking Example

<!--?php namespace Tests\Feature; use App\Models\User; use App\Services\PaymentService; use Illuminate\Foundation\Testing\RefreshDatabase; use Mockery; use Tests\TestCase; class PaymentTest extends TestCase { use RefreshDatabase; public function test_successful_payment() { $user = User::factory()->create();</p>
<p>        // Mock the payment service<br ?--> $paymentService = Mockery::mock(PaymentService::class);
$paymentService->shouldReceive('charge')
->once()
->with($user, 100.00)
->andReturn(['status' => 'success']);

$this->app->instance(PaymentService::class, $paymentService);

$response = $this->actingAs($user)->post('/payment/charge', [
'amount' => 100.00
]);

$response->assertOk()
->assertJson(['status' => 'success']);
}
}

10. Best Practices

Test Organization

  • Use descriptive test method names that explain what is being tested
  • Follow the AAA pattern: Arrange, Act, Assert
  • Keep tests simple and focused on one thing
  • Use factories instead of creating records manually

Database Testing

// Always use RefreshDatabase trait for database tests
class PostTest extends TestCase
{
use RefreshDatabase;

public function test_example()
{
// Test implementation
}
}

// Use factories for test data
$user = User::factory()->create(['email' => '[email protected]']);
$posts = Post::factory()->count(5)->create();

// Use specific assertions
$this->assertDatabaseHas('users', ['email' => '[email protected]']);
$this->assertDatabaseCount('posts', 5);
$this->assertDatabaseMissing('posts', ['id' => 999]);

Testing HTTP Requests

// Test both success and failure scenarios
public function test_successful_login()
{
$user = User::factory()->create(['password' => Hash::make('password')]);

$response = $this->post('/login', [
'email' => $user->email,
'password' => 'password'
]);

$response->assertRedirect('/dashboard');
$this->assertAuthenticated();
}

public function test_failed_login()
{
$response = $this->post('/login', [
'email' => '[email protected]',
'password' => 'wrong-password'
]);

$response->assertSessionHasErrors();
$this->assertGuest();
}

Common Assertions

// HTTP Response Assertions
$response->assertOk(); // 200
$response->assertCreated(); // 201
$response->assertNoContent(); // 204
$response->assertNotFound(); // 404
$response->assertForbidden(); // 403
$response->assertUnauthorized(); // 401

// Database Assertions
$this->assertDatabaseHas('users', ['email' => '[email protected]']);
$this->assertDatabaseMissing('users', ['email' => '[email protected]']);
$this->assertDatabaseCount('posts', 5);

// Authentication Assertions
$this->assertAuthenticated();
$this->assertAuthenticatedAs($user);
$this->assertGuest();

// View Assertions
$response->assertViewIs('posts.index');
$response->assertViewHas('posts');
$response->assertSeeText('Welcome');
$response->assertDontSeeText('Hidden');

// JSON Assertions
$response->assertJson(['status' => 'success']);
$response->assertJsonCount(3, 'data');
$response->assertJsonStructure(['data' => ['*' => ['id', 'name']]]);

Testing Environment

// Use environment-specific configuration
// In .env.testing
APP_ENV=testing
DB_CONNECTION=sqlite
DB_DATABASE=:memory:
CACHE_DRIVER=array
SESSION_DRIVER=array
QUEUE_CONNECTION=sync
MAIL_MAILER=array

// Create test-specific helpers
class TestCase extends BaseTestCase
{
protected function createUser($attributes = [])
{
return User::factory()->create($attributes);
}

protected function loginAs($user = null)
{
$user = $user ?: $this->createUser();
$this->actingAs($user);
return $user;
}
}

This guide covers the essential aspects of Laravel testing, from basic unit tests to feature tests and API testing. Follow these patterns and best practices to build a reliable test suite for your Laravel applications.