Laravel Testing Reference Guide

A Laravel testing reference guide: feature and unit tests with PHPUnit or Pest, database testing, and mocking.

PHPUnit vs Pest

A fresh Laravel install ships with PHPUnit configured out of the box, using class-based test methods. Pest is a thin, function-based layer on top of the same PHPUnit runner — same assertions, same test runner, different syntax — and is what laravel new offers by default today. Both run via php artisan test; picking one is a team preference rather than a capability difference, and existing PHPUnit test classes keep working even in a project that adds Pest.

// PHPUnit style
public function test_homepage_loads(): void
{
    $this->get('/')->assertOk();
}

// Pest style
test('homepage loads', function () {
    $this->get('/')->assertOk();
});

Feature tests vs unit tests

Laravel splits tests into two directories with different intentions. tests/Feature boots the full framework — routing, middleware, the database — and is the default choice for anything that touches a controller, route, or view, since it exercises the same path a real request takes. tests/Unit tests a single class in isolation without booting the framework, which makes it faster but means it can't touch the database, the container, or facades that rely on the app being bootstrapped.

php artisan make:test SearchControllerTest          // feature test
php artisan make:test PriceCalculatorTest --unit    // unit test

In practice, most Laravel applications lean heavily on feature tests — a controller, its middleware, its validation, and the view it returns are usually tested together as one unit of behaviour, and unit tests are reserved for genuinely standalone logic like a pricing calculation or a string formatter with no framework dependencies.

Making HTTP requests

$response = $this->get('/search?q=widgets');
$response = $this->post('/subscriptions', ['plan' => 'pro']);
$response = $this->postJson('/api/subscriptions', ['plan' => 'pro']);

$response->assertOk();                          // 200
$response->assertRedirect('/dashboard');
$response->assertStatus(422);
$response->assertJson(['plan' => 'pro']);
$response->assertJsonPath('results.0.title', 'Widgets');
$response->assertViewIs('search.results');
$response->assertSee('No results found');

Acting as a user

$user = User::factory()->create();

$this->actingAs($user)
    ->get('/dashboard')
    ->assertOk();

Combine with a policy to test authorization boundaries — that a user can only edit their own resources, for instance — by asserting a 403 for one user and a 200 for another against the same route.

Database testing

The RefreshDatabase trait wraps each test in a transaction that's rolled back afterward, so tests can freely create and modify rows without leaking state between tests or needing to manually clean up:

use Illuminate\Foundation\Testing\RefreshDatabase;

class SubscriptionTest extends TestCase
{
    use RefreshDatabase;

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

        $this->actingAs($user)->post('/subscriptions', ['plan' => 'pro']);

        $this->assertDatabaseHas('subscriptions', [
            'user_id' => $user->id,
            'plan' => 'pro',
        ]);
    }
}

Model factories (database/factories) generate realistic fake rows for exactly this purpose, and --parallel test runs give each process its own test database automatically when RefreshDatabase is in use.

$this->assertDatabaseCount('subscriptions', 1);
$this->assertDatabaseMissing('subscriptions', ['plan' => 'free']);
$this->assertSoftDeleted($post);   // deleted_at is set, row still exists

A separate .env.testing file overrides .env just for the test run — the common setup is DB_CONNECTION=sqlite with DB_DATABASE=:memory:, which makes the whole suite fast since there's no real database process to round-trip to, at the cost of testing against slightly different SQL behaviour than production.

Testing file uploads

Storage::fake('avatars');

$file = UploadedFile::fake()->image('avatar.jpg', 300, 300)->size(100); // KB

$this->post('/profile/avatar', ['avatar' => $file])->assertOk();

Storage::disk('avatars')->assertExists($file->hashName());

Storage::fake() redirects writes to a temporary local disk for the test instead of touching the real filesystem/S3 bucket, and UploadedFile::fake() generates a dummy file (or image, with real dimensions) without needing a fixture file checked into the repo.

Mocking and faking facades

Laravel's facades ship with built-in fakes so a test can assert an outbound email, job, or notification was triggered without actually sending it:

Mail::fake();
Queue::fake();
Notification::fake();
Http::fake(['api.example.com/*' => Http::response(['ok' => true], 200)]);

// ... run the code under test ...

Mail::assertSent(WelcomeEmail::class);
Queue::assertPushed(ProcessPayment::class);
Http::assertSent(fn ($request) => $request->url() === 'https://api.example.com/users');

Faking is almost always preferable to mocking a class directly — it keeps the test focused on outcomes ("was this job dispatched?") rather than implementation details, and needs no setup beyond the one ::fake() call.

Time and randomness

$this->travelTo(now()->addDays(30));   // freeze/move the clock for the test
$this->travelBack();

$this->withoutMiddleware();            // skip all middleware for this test
$this->withoutMiddleware(ThrottleRequests::class); // skip just one

Time travel is the standard way to test anything gated by now() — a subscription expiring, a trial ending — without actually waiting or hardcoding dates that will eventually go stale.

Running the same test against multiple inputs

test('discount is applied correctly', function (int $total, int $expected) {
    expect(applyDiscount($total))->toBe($expected);
})->with([
    [100, 90],
    [50, 45],
    [0, 0],
]);

Pest's ->with() (a data provider, in PHPUnit terms) reruns the same test body once per row, which keeps a set of edge cases readable as a table of inputs/outputs instead of a copy-pasted test per case.

Testing JavaScript-dependent flows

Everything above runs against the framework directly, without a real browser — fine for asserting HTML/JSON output, but blind to anything that only happens after JavaScript runs (an Alpine/Livewire interaction, a modal, a file drag-and-drop). Laravel Dusk drives an actual browser via ChromeDriver for exactly those flows: php artisan dusk:install, then write tests against real DOM interactions ($browser->click(), ->type(), ->assertSee()). It's slower than a feature test and worth reaching for only when the behaviour genuinely can't be verified any other way.