Testing Laravel Apps

Laravel Intermediate/Advanced
Course 2 ยท Chapter 3 ยท Testing Laravel Apps

๐Ÿงช Testing Laravel Apps

Laravel tests run on PHPUnit โ€” a real, standard PHP testing framework Laravel didn't invent, just wraps with a helpful base class and a fluent HTTP testing API. One real gap from Django worth flagging immediately: Django's per-test database rollback is automatic by inheriting TestCase; Laravel's equivalent is a trait you have to remember to add.

Feature Tests vs Unit Tests

Laravel draws an explicit line Django doesn't โ€” tests/Feature/ exercises the full stack through an HTTP request, tests/Unit/ tests a single class in isolation with no framework bootstrapping at all:

Feature Test

Simulates a real HTTP request through routing, middleware, controller, and response โ€” Django's self.client.get()-style tests fill this same role.

Unit Test

Tests one class or method directly โ€” a plain PHPUnit test with no Laravel application booted, useful for a pure calculation or validator method.

๐Ÿ”„ RefreshDatabase: Opt-In, Not Automatic

This is the sharpest difference from Django in this whole course. Django's TestCase wraps every test in a rollback transaction automatically, just by inheriting it. Laravel requires an explicit trait:

// tests/Feature/BookTest.php
namespace Tests\Feature;

use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class BookTest extends TestCase
{
    use RefreshDatabase;  // โš ๏ธ without this line, tests share a real, un-reset database

    public function test_book_list_returns_200(): void
    {
        $response = $this->get('/books');
        $response->assertStatus(200);
    }
}

Automatic (Django) vs Opt-In (Laravel)

Django

class BookTests(TestCase): โ€” inheriting TestCase is the whole story. Every test method is automatically wrapped in a rollback transaction; there's no separate step to remember.

Laravel

use RefreshDatabase; must be added explicitly inside the class โ€” extending Tests\TestCase alone does not reset the database between tests.

The HTTP Testing API

Laravel's fluent testing methods read close to Django's self.client, with more built-in assertion helpers:

$response = $this->get('/books');
$response->assertStatus(200);
$response->assertSee('The Dispossessed');
$response->assertViewIs('books.index');

// Testing an authenticated route (Chapter 1's auth middleware)
$response = $this->actingAs($user)->get('/dashboard');
$response->assertStatus(200);

// Testing a JSON API endpoint (Chapter 2's API Resources)
$response = $this->getJson('/api/books');
$response->assertJson(['data' => []]);

actingAs($user)

Simulates a logged-in session for the test โ€” the direct equivalent of manually calling self.client.login() in Django, just one method instead of two steps.

assertViewIs()

Confirms which Blade view actually rendered โ€” the same job as Django's assertTemplateUsed.

๐Ÿญ Model Factories

A genuinely nice built-in feature Django doesn't have a core equivalent for โ€” Django developers commonly reach for a third-party library like factory_boy; Laravel ships factories as part of the framework itself:

php artisan make:factory BookFactory
// database/factories/BookFactory.php
namespace Database\Factories;

use Illuminate\Database\Eloquent\Factories\Factory;

class BookFactory extends Factory
{
    public function definition(): array
    {
        return [
            'title' => fake()->sentence(3),
            'price' => fake()->randomFloat(2, 5, 50),
        ];
    }
}
// In a test:
$book = Book::factory()->create();               // one realistic, saved Book
$books = Book::factory()->count(10)->create();  // ten of them
$book = Book::factory()->make(['price' => 0]);  // override one field, don't save

๐Ÿ’ป Coding Challenges

Challenge 1: Write a Feature Test

Write a feature test class for the book detail route that uses RefreshDatabase, creates a book with a factory, and asserts the response is a 200 containing the book's title.

Goal: Practice the full RefreshDatabase + factory + HTTP assertion workflow.

โ†’ Solution

Challenge 2: Test an Authenticated Route

Write a test using actingAs() to confirm a logged-in user can reach /dashboard, and a second test confirming an anonymous request is redirected instead.

Goal: Practice testing both the success and the access-control-denied path for a protected route.

โ†’ Solution

Challenge 3: Diagnose a Test Suite Missing RefreshDatabase

Given a test class that extends Tests\TestCase but omits use RefreshDatabase;, and two tests that each create a Book and assert Book::count() equals 1 โ€” explain why the second test might fail when run after the first, and fix it.

Goal: Practice recognizing test pollution caused by the missing trait, and understand exactly why it happens.

โ†’ Solution

โš ๏ธ Gotcha: Forgetting RefreshDatabase

This is the sharpest version of a pattern this project has built toward โ€” Django simply doesn't let you make this mistake, because the rollback is baked into TestCase itself. In Laravel, a test class that extends Tests\TestCase but omits use RefreshDatabase; runs every test against the same real database, with no cleanup between tests at all. Data created in one test persists into the next, test order starts mattering, and a suite that passes in isolation can fail unpredictably when run as a whole โ€” classic flaky-test symptoms, all traceable to one missing line. Add the trait to every feature test that touches the database, as a default habit, not an afterthought.

๐ŸŽฏ What's Next

With a real test suite in place, the next chapter looks at code that reacts to events rather than sitting inline in a controller: Events & Listeners โ€” Laravel's decoupled event system compared to Django's signals.