Back to Blog
Lesson 38 of the Intermediate Laravel: Real-World Application Patterns course
LaravelJune 26, 20264 min read

Advanced Testing: Integration Tests in Laravel

Master integration testing in Laravel. Learn how to manage database state, verify multi-component workflows, and ensure your application logic works in unison.

LaravelTestingIntegration TestingPHPDatabasebackend

Previously in this course, we explored Testing Events and Jobs in Laravel to ensure our asynchronous processes were firing correctly. Now, we're zooming out to the "Integration" level, where we verify that our services, repositories, and database constraints actually play nice together.

When you unit test, you mock everything. But in a real-world Laravel application, your code lives and dies by its side effects: a row created in a table, a relationship established between models, and a state change that ripples across your system. Integration testing is about ensuring these connections aren't just theoretically correct, but practically functional.

Why Integration Testing Matters

In our project board application, a "Task Creation" workflow involves the TaskService, the TaskRepository, and several database-level constraints (like project ownership). If we only mock the repository, we might miss a scenario where a database constraint violation—like a foreign key mismatch—breaks the user experience.

Integration testing allows us to:

  1. Verify Database Persistence: Ensure data is actually saved correctly.
  2. Test Eloquent Relationships: Confirm that task->project returns the expected instance.
  3. Validate Business Workflows: Ensure that the sequence of operations (e.g., creating a task, assigning it to a user, and updating the project's task count) happens atomically.

Managing Database State

The biggest pain point in integration testing is database pollution. You don't want tests to leak state into each other. Laravel handles this natively using the Illuminate\Foundation\Testing\RefreshDatabase trait.

When you include this trait in your test class, Laravel performs two steps:

  1. It migrates your database before the test suite runs (or on the first test).
  2. It wraps every individual test in a database transaction, rolling it back immediately after the test completes.

A Concrete Worked Example

Let’s test our TaskService in a real integration scenario. We want to verify that when a task is created, it is correctly associated with the project and that our business logic (like setting a default status) is applied.

PHP
namespace Tests\Integration;

use Tests\TestCase;
use App\Models\Project;
use App\Models\User;
use App\Services\TaskService;
use Illuminate\Foundation\Testing\RefreshDatabase;

class TaskWorkflowTest extends TestCase
{
    use RefreshDatabase;

    public function test_task_creation_workflow_persists_correctly()
    {
        #6A9955">// 1. Setup: Use factories to prepare the environment
        $user = User::factory()->create();
        $project = Project::factory()->for($user)->create();
        $service = app(TaskService::class);

        #6A9955">// 2. Action: Execute the service method
        $task = $service->createTask($project, [
            'title' => 'Complete documentation',
            'description' => 'Write the API guide'
        ]);

        #6A9955">// 3. Assertion: Verify the database state directly
        $this->assertDatabaseHas('tasks', [
            'id' => $task->id,
            'project_id' => $project->id,
            'status' => 'pending' #6A9955">// Our default business logic
        ]);

        $this->assertEquals(1, $project->tasks()->count());
    }
}

By using assertDatabaseHas, we aren't just checking if the object exists in memory; we are querying the database to ensure the persistence layer captured the data exactly as intended.

Hands-on Exercise

Using the project board we’ve been building:

  1. Create a new test file: php artisan make:test Integration/ProjectAssignmentTest.
  2. Use the RefreshDatabase trait.
  3. Write a test that:
    • Creates a Project and two Users.
    • Uses your ProjectService to assign the second user to the project.
    • Asserts that the project_user pivot table contains the correct record.
  4. Run the test with php artisan test.

Common Pitfalls

Even senior engineers run into these issues when writing integration tests:

  • Over-mocking: If you find yourself using Mockery or Event::fake() inside an integration test, ask yourself if you're still testing the integration. Keep integration tests focused on the real code interaction. Reference Mocking Services and Repositories in Laravel Tests to understand when to shift from mocking to true integration.
  • Seeding Bloat: Avoid using large seeders in your setUp() method. Use Mastering Laravel Database Factories and Seeding for Testing to create minimal, specific data for the test at hand.
  • Ignoring Transactional Boundaries: If your service uses DB::transaction, make sure your test doesn't accidentally commit data that breaks subsequent tests. Laravel’s RefreshDatabase is generally smart enough, but complex manual transactions can sometimes interfere with the test runner's rollback mechanism.

Recap

Integration testing bridges the gap between isolated logic and the real-world behavior of your application. By leveraging the RefreshDatabase trait and focusing on database assertions, you ensure that your services, repositories, and database schema work together as a cohesive system. This level of testing is your best defense against regression in complex workflows.

Up next: We will dive into testing API authentication to ensure our protected endpoints remain secure.

Similar Posts