Testing is a critical aspect of professional Laravel development. Well-tested applications are more reliable, easier to maintain, and less prone to bugs. Laravel provides excellent testing tools and features that make writing tests straightforward and effective.
In this comprehensive guide, we'll explore Laravel testing strategies covering unit tests, feature tests, and integration tests. You'll learn how to write effective tests, use Laravel's testing features, and implement best practices for maintaining high-quality code.
Why Testing Matters in Laravel
Testing provides numerous benefits for Laravel applications:
- Bug Prevention: Catch errors before they reach production
- Code Confidence: Refactor and update code with confidence
- Documentation: Tests serve as living documentation
- Regression Prevention: Ensure new changes don't break existing functionality
- Faster Development: Automated tests save time in the long run
Laravel Testing Tools
PHPUnit Integration
Laravel uses PHPUnit as its testing framework, which comes pre-configured:
// phpunit.xml is already configured
// Run all tests
php artisan test
// Run specific test file
php artisan test tests/Feature/ExampleTest.php
// Run with coverage
php artisan test --coverage
Laravel's Testing Features
Laravel provides powerful testing helpers:
- Database Testing: Automatic database transactions
- HTTP Testing: Simulate HTTP requests
- Authentication Testing: Test authenticated users
- Queue Testing: Test queued jobs
- Mail Testing: Assert emails are sent
Types of Tests
Unit Tests
Unit tests focus on testing individual components in isolation:
// tests/Unit/UserServiceTest.php
namespace Tests\Unit;
use Tests\TestCase;
use App\Services\UserService;
use Illuminate\Foundation\Testing\RefreshDatabase;
class UserServiceTest extends TestCase
{
use RefreshDatabase;
public function test_can_create_user()
{
$service = new UserService();
$user = $service->createUser([
'name' => 'John Doe',
'email' => 'john@example.com',
]);
$this->assertNotNull($user);
$this->assertEquals('John Doe', $user->name);
$this->assertEquals('john@example.com', $user->email);
}
}
Feature Tests
Feature tests test complete features from the user's perspective:
// tests/Feature/UserRegistrationTest.php
namespace Tests\Feature;
use Tests\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;
class UserRegistrationTest extends TestCase
{
use RefreshDatabase;
public function test_user_can_register()
{
$response = $this->post('/register', [
'name' => 'John Doe',
'email' => 'john@example.com',
'password' => 'password123',
'password_confirmation' => 'password123',
]);
$response->assertRedirect('/home');
$this->assertDatabaseHas('users', [
'email' => 'john@example.com',
]);
}
}
Integration Tests
Integration tests verify that multiple components work together:
// tests/Feature/OrderProcessingTest.php
namespace Tests\Feature;
use Tests\TestCase;
use App\Models\User;
use App\Models\Product;
use Illuminate\Foundation\Testing\RefreshDatabase;
class OrderProcessingTest extends TestCase
{
use RefreshDatabase;
public function test_complete_order_flow()
{
$user = User::factory()->create();
$product = Product::factory()->create();
// Login
$this->actingAs($user);
// Add to cart
$this->post('/cart/add', [
'product_id' => $product->id,
'quantity' => 2,
]);
// Checkout
$response = $this->post('/checkout', [
'payment_method' => 'stripe',
]);
$response->assertRedirect('/orders');
$this->assertDatabaseHas('orders', [
'user_id' => $user->id,
]);
}
}
Writing Unit Tests
Testing Models and Services
// tests/Unit/UserTest.php
namespace Tests\Unit;
use Tests\TestCase;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
class UserTest extends TestCase
{
use RefreshDatabase;
public function test_user_has_full_name()
{
$user = User::factory()->create([
'first_name' => 'John',
'last_name' => 'Doe',
]);
$this->assertEquals('John Doe', $user->full_name);
}
public function test_user_can_be_activated()
{
$user = User::factory()->create(['active' => false]);
$user->activate();
$this->assertTrue($user->fresh()->active);
}
}
Mocking Dependencies
// tests/Unit/PaymentServiceTest.php
namespace Tests\Unit;
use Tests\TestCase;
use App\Services\PaymentService;
use App\Services\PaymentGateway;
use Mockery;
class PaymentServiceTest extends TestCase
{
public function test_processes_payment()
{
$gateway = Mockery::mock(PaymentGateway::class);
$gateway->shouldReceive('charge')
->once()
->with(100, 'token123')
->andReturn(['success' => true]);
$service = new PaymentService($gateway);
$result = $service->processPayment(100, 'token123');
$this->assertTrue($result['success']);
}
}
Writing Feature Tests
Testing Routes and Controllers
// tests/Feature/PostControllerTest.php
namespace Tests\Feature;
use Tests\TestCase;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
class PostControllerTest extends TestCase
{
use RefreshDatabase;
public function test_can_list_posts()
{
$response = $this->get('/posts');
$response->assertStatus(200);
$response->assertViewIs('posts.index');
}
public function test_can_create_post()
{
$user = User::factory()->create();
$this->actingAs($user);
$response = $this->post('/posts', [
'title' => 'Test Post',
'content' => 'Test content',
]);
$response->assertRedirect('/posts');
$this->assertDatabaseHas('posts', [
'title' => 'Test Post',
'user_id' => $user->id,
]);
}
}
Testing API Endpoints
// tests/Feature/ApiTest.php
namespace Tests\Feature;
use Tests\TestCase;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
class ApiTest extends TestCase
{
use RefreshDatabase;
public function test_can_get_user_data()
{
$user = User::factory()->create();
$response = $this->actingAs($user, 'api')
->getJson('/api/user');
$response->assertStatus(200)
->assertJson([
'id' => $user->id,
'email' => $user->email,
]);
}
public function test_requires_authentication()
{
$response = $this->getJson('/api/user');
$response->assertStatus(401);
}
}
Database Testing
Using Database Transactions
Laravel automatically wraps tests in database transactions:
// RefreshDatabase trait handles transactions
use Illuminate\Foundation\Testing\RefreshDatabase;
class ExampleTest extends TestCase
{
use RefreshDatabase; // Database is reset after each test
}
Factories and Seeders
// Using factories
$user = User::factory()->create();
$users = User::factory()->count(10)->create();
// Using factories with relationships
$post = Post::factory()
->for(User::factory())
->create();
// Using seeders
$this->seed(); // Run all seeders
$this->seed(DatabaseSeeder::class); // Run specific seeder
Testing Best Practices
Test Organization
- Keep tests organized by feature
- Use descriptive test names
- One assertion per test (when possible)
- Test edge cases and error conditions
Naming Conventions
// Good test names
public function test_user_can_login_with_valid_credentials()
public function test_user_cannot_login_with_invalid_password()
public function test_guest_cannot_access_admin_panel()
// Bad test names
public function test1()
public function test_login()
public function test_user()
Test Coverage Goals
- Aim for 80%+ coverage on critical paths
- Focus on business logic first
- Test user-facing features thoroughly
- Don't obsess over 100% coverage
Common Testing Scenarios
Testing Forms and Validation
public function test_validation_errors_on_invalid_input()
{
$response = $this->post('/register', [
'email' => 'invalid-email',
'password' => '123',
]);
$response->assertSessionHasErrors(['email', 'password']);
}
public function test_form_submission_succeeds()
{
$response = $this->post('/contact', [
'name' => 'John Doe',
'email' => 'john@example.com',
'message' => 'Test message',
]);
$response->assertRedirect('/contact')
->assertSessionHas('success');
}
Testing Email Sending
use Illuminate\Support\Facades\Mail;
public function test_sends_welcome_email()
{
Mail::fake();
$user = User::factory()->create();
// Trigger email sending
event(new UserRegistered($user));
Mail::assertSent(WelcomeEmail::class, function ($mail) use ($user) {
return $mail->hasTo($user->email);
});
}
Testing File Uploads
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
public function test_can_upload_file()
{
Storage::fake('public');
$file = UploadedFile::fake()->image('avatar.jpg');
$response = $this->post('/profile/avatar', [
'avatar' => $file,
]);
Storage::disk('public')->assertExists('avatars/' . $file->hashName());
}
Testing Queues
use Illuminate\Support\Facades\Queue;
public function test_job_is_queued()
{
Queue::fake();
ProcessOrder::dispatch($order);
Queue::assertPushed(ProcessOrder::class);
}
public function test_job_runs_synchronously()
{
Queue::fake();
ProcessOrder::dispatchSync($order);
Queue::assertPushed(ProcessOrder::class);
// Job runs immediately in tests
}
Continuous Integration
Running Tests in CI/CD
# .github/workflows/tests.yml
name: Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.2'
- name: Install Dependencies
run: composer install
- name: Run Tests
run: php artisan test
Conclusion
Laravel testing strategies are essential for building reliable, maintainable applications. By implementing comprehensive unit tests, feature tests, and integration tests, you can ensure your application works correctly and remains stable as it evolves.
Laravel's testing tools make it easy to write effective tests. With PHPUnit integration, powerful testing helpers, and excellent documentation, you have everything you need to implement a robust testing strategy.
Working with experienced Laravel developers who understand testing best practices can help you build applications that are reliable, maintainable, and ready for production.