Laravel Testing Reference Guide
A Laravel testing reference guide: feature and unit tests with PHPUnit or Pest, database testing, and mocking.
Laravel Reference
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 behavior, 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.
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.