Introduction
Testing is the foundation of reliable, maintainable software. Comprehensive tests catch bugs early, enable confident refactoring, and serve as living documentation. Laravel 12 provides powerful testing tools built on PHPUnit and Pest, making it easy to write tests that ensure your application works correctly.
Whether you're practicing Test-Driven Development (TDD) or adding tests to existing code, Laravel's testing utilities simplify database interactions, HTTP requests, authentication, and more. Well-tested applications are easier to maintain, scale, and deploy with confidence.
In this comprehensive guide, we'll explore testing in Laravel 12, from basic unit tests to complex feature tests, database testing, mocking strategies, and best practices that will help you build robust, reliable applications.
Setting Up Your Testing Environment
1. PHPUnit vs Pest
Laravel 12 supports both PHPUnit and Pest testing frameworks:
PHPUnit (Traditional, class-based):
namespace Tests\Feature;
use Tests\TestCase;
class ExampleTest extends TestCase
{
public function test_basic_test(): void
{
$this->assertTrue(true);
}
}
Pest (Modern, function-based):
test('basic test', function () {
expect(true)->toBeTrue();
});
Install Pest:
composer require pestphp/pest --dev --with-all-dependencies
composer require pestphp/pest-plugin-laravel --dev
php artisan pest:install
2. Configure Testing Database
In phpunit.xml:
<?xml version="1.0" encoding="UTF-8"?>
<phpunit bootstrap="vendor/autoload.php">
<testsuites>
<testsuite name="Unit">
<directory>tests/Unit</directory>
</testsuite>
<testsuite name="Feature">
<directory>tests/Feature</directory>
</testsuite>
</testsuites>
<php>
<env name="APP_ENV" value="testing"/>
<env name="DB_CONNECTION" value="sqlite"/>
<env name="DB_DATABASE" value=":memory:"/>
<env name="CACHE_DRIVER" value="array"/>
<env name="SESSION_DRIVER" value="array"/>
<env name="QUEUE_CONNECTION" value="sync"/>
</php>
</phpunit>
3. Running Tests
# Run all tests
php artisan test
# Run specific test file
php artisan test tests/Feature/UserTest.php
# Run specific test method
php artisan test --filter test_user_can_login
# Run with coverage
php artisan test --coverage
# Parallel testing
php artisan test --parallel
Unit Testing
Unit tests verify individual methods and classes in isolation.
1. Basic Unit Tests
namespace Tests\Unit;
use Tests\TestCase;
use App\Services\DiscountCalculator;
class DiscountCalculatorTest extends TestCase
{
/** @test */
public function it_calculates_percentage_discount()
{
$calculator = new DiscountCalculator();
$result = $calculator->calculate(100, 10);
$this->assertEquals(90, $result);
}
/** @test */
public function it_returns_zero_for_invalid_discount()
{
$calculator = new DiscountCalculator();
$result = $calculator->calculate(100, 150);
$this->assertEquals(100, $result);
}
/** @test */
public function it_handles_edge_cases()
{
$calculator = new DiscountCalculator();
$this->assertEquals(0, $calculator->calculate(100, 100));
$this->assertEquals(100, $calculator->calculate(100, 0));
}
}
2. Testing Private Methods
Use reflection or refactor to make testable:
class PriceCalculator
{
public function calculateTotalPrice(array $items): float
{
$subtotal = $this->calculateSubtotal($items);
$tax = $this->calculateTax($subtotal);
return $subtotal + $tax;
}
// Protected instead of private for testing
protected function calculateSubtotal(array $items): float
{
return array_sum(array_column($items, 'price'));
}
protected function calculateTax(float $amount): float
{
return $amount * 0.1;
}
}
// Test
class PriceCalculatorTest extends TestCase
{
/** @test */
public function it_calculates_total_price_with_tax()
{
$calculator = new PriceCalculator();
$items = [
['price' => 100],
['price' => 50],
];
$total = $calculator->calculateTotalPrice($items);
$this->assertEquals(165, $total); // 150 + 15 (10% tax)
}
}
3. Data Providers
Test multiple scenarios efficiently:
class ValidationTest extends TestCase
{
/**
* @test
* @dataProvider emailProvider
*/
public function it_validates_email_addresses($email, $expected)
{
$validator = new EmailValidator();
$this->assertEquals($expected, $validator->isValid($email));
}
public static function emailProvider(): array
{
return [
['test@example.com', true],
['invalid.email', false],
['test@sub.domain.com', true],
['test@', false],
['@example.com', false],
['test+tag@example.com', true],
];
}
}
Feature Testing
Feature tests verify complete application workflows and HTTP interactions.
1. HTTP Testing
namespace Tests\Feature;
use Tests\TestCase;
use App\Models\User;
class PostControllerTest extends TestCase
{
/** @test */
public function guest_cannot_create_post()
{
$response = $this->post('/posts', [
'title' => 'Test Post',
'content' => 'Test content',
]);
$response->assertRedirect('/login');
}
/** @test */
public function authenticated_user_can_create_post()
{
$user = User::factory()->create();
$response = $this->actingAs($user)
->post('/posts', [
'title' => 'Test Post',
'content' => 'Test content with minimum length',
]);
$response->assertRedirect();
$response->assertSessionHas('success');
$this->assertDatabaseHas('posts', [
'title' => 'Test Post',
'user_id' => $user->id,
]);
}
/** @test */
public function validation_errors_are_returned()
{
$user = User::factory()->create();
$response = $this->actingAs($user)
->post('/posts', [
'title' => '', // Invalid
'content' => 'Short', // Too short
]);
$response->assertSessionHasErrors(['title', 'content']);
}
}
2. JSON API Testing
class ApiPostControllerTest extends TestCase
{
/** @test */
public function it_returns_paginated_posts()
{
Post::factory()->count(30)->create();
$response = $this->getJson('/api/posts');
$response->assertOk()
->assertJsonStructure([
'data' => [
'*' => ['id', 'title', 'content', 'created_at']
],
'links',
'meta'
])
->assertJsonCount(15, 'data');
}
/** @test */
public function it_creates_post_via_api()
{
$user = User::factory()->create();
$response = $this->actingAs($user)
->postJson('/api/posts', [
'title' => 'API Test Post',
'content' => 'Content from API test',
]);
$response->assertCreated()
->assertJson([
'data' => [
'title' => 'API Test Post',
]
]);
}
/** @test */
public function it_returns_404_for_nonexistent_post()
{
$response = $this->getJson('/api/posts/999');
$response->assertNotFound();
}
}
3. Testing Authentication
class AuthenticationTest extends TestCase
{
/** @test */
public function user_can_login_with_valid_credentials()
{
$user = User::factory()->create([
'password' => Hash::make('password123'),
]);
$response = $this->post('/login', [
'email' => $user->email,
'password' => 'password123',
]);
$response->assertRedirect('/dashboard');
$this->assertAuthenticatedAs($user);
}
/** @test */
public function user_cannot_login_with_invalid_password()
{
$user = User::factory()->create([
'password' => Hash::make('password123'),
]);
$response = $this->post('/login', [
'email' => $user->email,
'password' => 'wrongpassword',
]);
$response->assertSessionHasErrors('email');
$this->assertGuest();
}
/** @test */
public function user_can_logout()
{
$user = User::factory()->create();
$response = $this->actingAs($user)
->post('/logout');
$response->assertRedirect('/');
$this->assertGuest();
}
}
Database Testing
1. Migrations and Factories
class UserTest extends TestCase
{
use RefreshDatabase; // Automatically migrates database
/** @test */
public function it_creates_user_with_factory()
{
$user = User::factory()->create([
'name' => 'John Doe',
]);
$this->assertDatabaseHas('users', [
'name' => 'John Doe',
]);
}
/** @test */
public function it_creates_multiple_users()
{
User::factory()->count(5)->create();
$this->assertDatabaseCount('users', 5);
}
}
2. Factory States and Relationships
// Define factory states
namespace Database\Factories;
use Illuminate\Database\Eloquent\Factories\Factory;
class UserFactory extends Factory
{
public function definition(): array
{
return [
'name' => fake()->name(),
'email' => fake()->unique()->safeEmail(),
'password' => Hash::make('password'),
];
}
public function admin(): static
{
return $this->state(fn (array $attributes) => [
'role' => 'admin',
]);
}
public function verified(): static
{
return $this->state(fn (array $attributes) => [
'email_verified_at' => now(),
]);
}
}
// Use in tests
$admin = User::factory()->admin()->verified()->create();
// Create with relationships
$user = User::factory()
->has(Post::factory()->count(3))
->create();
// Or
$posts = Post::factory()
->for(User::factory())
->count(5)
->create();
3. Database Assertions
/** @test */
public function it_deletes_related_records()
{
$user = User::factory()->create();
$post = Post::factory()->for($user)->create();
$user->delete();
$this->assertDatabaseMissing('users', ['id' => $user->id]);
$this->assertDatabaseMissing('posts', ['id' => $post->id]);
}
/** @test */
public function it_soft_deletes_user()
{
$user = User::factory()->create();
$user->delete();
$this->assertSoftDeleted('users', ['id' => $user->id]);
}
/** @test */
public function it_counts_records_correctly()
{
User::factory()->count(3)->create();
$this->assertDatabaseCount('users', 3);
}
Mocking and Test Doubles
1. Mocking Dependencies
use Mockery;
use App\Services\PaymentGateway;
class OrderServiceTest extends TestCase
{
/** @test */
public function it_processes_payment_on_order_creation()
{
// Create mock
$paymentGateway = Mockery::mock(PaymentGateway::class);
// Set expectations
$paymentGateway->shouldReceive('charge')
->once()
->with(100.00, 'card_token')
->andReturn(true);
// Bind mock to container
$this->app->instance(PaymentGateway::class, $paymentGateway);
// Test code
$orderService = app(OrderService::class);
$order = $orderService->createOrder([
'amount' => 100.00,
'payment_token' => 'card_token',
]);
$this->assertTrue($order->isPaid());
}
}
2. Faking Laravel Facades
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\Storage;
use App\Mail\OrderConfirmation;
class OrderTest extends TestCase
{
/** @test */
public function it_sends_confirmation_email()
{
Mail::fake();
$order = Order::factory()->create();
Mail::assertSent(OrderConfirmation::class, function ($mail) use ($order) {
return $mail->order->id === $order->id;
});
}
/** @test */
public function it_queues_export_job()
{
Queue::fake();
$this->post('/orders/export');
Queue::assertPushed(ExportOrdersJob::class);
}
/** @test */
public function it_stores_file_in_storage()
{
Storage::fake('public');
$response = $this->post('/upload', [
'file' => UploadedFile::fake()->image('photo.jpg'),
]);
Storage::disk('public')->assertExists('uploads/photo.jpg');
}
}
3. Event Faking
use Illuminate\Support\Facades\Event;
use App\Events\OrderPlaced;
/** @test */
public function it_dispatches_order_placed_event()
{
Event::fake();
$order = Order::factory()->create();
Event::assertDispatched(OrderPlaced::class, function ($event) use ($order) {
return $event->order->id === $order->id;
});
}
/** @test */
public function it_does_not_dispatch_event_for_draft_order()
{
Event::fake();
$order = Order::factory()->draft()->create();
Event::assertNotDispatched(OrderPlaced::class);
}
Testing Best Practices
1. Arrange-Act-Assert Pattern
/** @test */
public function it_applies_discount_to_order()
{
// Arrange - Set up test data
$order = Order::factory()->create(['total' => 100]);
$discount = Discount::factory()->create(['percentage' => 10]);
// Act - Execute the code being tested
$order->applyDiscount($discount);
// Assert - Verify the results
$this->assertEquals(90, $order->total);
$this->assertTrue($order->discounts->contains($discount));
}
2. Test Naming Conventions
// ✅ Descriptive test names
/** @test */
public function authenticated_user_can_view_their_profile() {}
/** @test */
public function guest_is_redirected_to_login_when_accessing_dashboard() {}
/** @test */
public function validation_fails_when_email_is_invalid() {}
// ❌ Poor test names
/** @test */
public function test_user() {}
/** @test */
public function it_works() {}
3. Test Organization
// Group related tests
class UserProfileTest extends TestCase
{
// Setup common data
protected User $user;
protected function setUp(): void
{
parent::setUp();
$this->user = User::factory()->create();
}
/** @test */
public function user_can_update_name() {}
/** @test */
public function user_can_update_email() {}
/** @test */
public function user_cannot_use_duplicate_email() {}
}
4. Test Independence
// ✅ Each test is independent
/** @test */
public function test_one()
{
$user = User::factory()->create();
// Test logic
}
/** @test */
public function test_two()
{
$user = User::factory()->create(); // Fresh user
// Test logic
}
// ❌ Tests depend on each other
protected User $sharedUser;
/** @test */
public function test_one()
{
$this->sharedUser = User::factory()->create();
}
/** @test */
public function test_two()
{
// Depends on test_one running first
$this->sharedUser->update(['name' => 'Updated']);
}
Advanced Testing Techniques
1. Testing Console Commands
class ImportUsersCommandTest extends TestCase
{
/** @test */
public function it_imports_users_from_csv()
{
Storage::fake('local');
Storage::disk('local')->put('users.csv', "name,email\nJohn,john@example.com");
$this->artisan('import:users')
->expectsOutput('Importing users...')
->expectsOutput('1 users imported successfully')
->assertExitCode(0);
$this->assertDatabaseHas('users', [
'email' => 'john@example.com',
]);
}
}
2. Testing Jobs
class ProcessOrderJobTest extends TestCase
{
/** @test */
public function it_processes_order_successfully()
{
$order = Order::factory()->create(['status' => 'pending']);
$job = new ProcessOrderJob($order);
$job->handle();
$this->assertEquals('processed', $order->fresh()->status);
}
/** @test */
public function it_retries_on_failure()
{
Queue::fake();
$order = Order::factory()->create();
ProcessOrderJob::dispatch($order);
Queue::assertPushed(ProcessOrderJob::class, function ($job) {
return $job->tries === 3;
});
}
}
3. Testing Middleware
class AdminMiddlewareTest extends TestCase
{
/** @test */
public function admin_can_access_admin_routes()
{
$admin = User::factory()->admin()->create();
$response = $this->actingAs($admin)
->get('/admin/dashboard');
$response->assertOk();
}
/** @test */
public function regular_user_cannot_access_admin_routes()
{
$user = User::factory()->create();
$response = $this->actingAs($user)
->get('/admin/dashboard');
$response->assertForbidden();
}
}
Test Coverage and CI/CD
1. Generate Coverage Reports
# Install Xdebug
php artisan test --coverage --min=80
# Generate HTML report
php artisan test --coverage-html coverage
2. GitHub Actions CI
# .github/workflows/tests.yml
name: Tests
on: [push, pull_request]
jobs:
tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: 8.3
extensions: mbstring, pdo_sqlite
- name: Install Dependencies
run: composer install --no-interaction
- name: Run Tests
run: php artisan test --parallel
Conclusion
Mastering testing in Laravel 12 ensures your applications are reliable, maintainable, and bug-free. By writing comprehensive unit and feature tests, using factories and mocking effectively, and following best practices, you build confidence in your code and enable fearless refactoring.
Key takeaways:
- Write both unit and feature tests
- Use factories for test data generation
- Mock external dependencies properly
- Follow AAA (Arrange-Act-Assert) pattern
- Maintain test independence
- Aim for high test coverage
- Integrate testing into CI/CD pipeline
Need help implementing comprehensive testing? NeedLaravelSite specializes in test-driven development and quality assurance. Contact us for expert Laravel development services.
Related Resources: