Browser testing with Laravel Dusk
Concept
Testing performance in Laravel means verifying that your code doesn't make excessive database queries, doesn't time out, and doesn't consume unexpected memory.
assertQueryCount(int $count): Available on both the TestCase and on individual requests. Assert the exact number of DB queries executed during a test. Catches N+1 regressions.
DB::enableQueryLog() / DB::getQueryLog(): Manually capture executed queries. Inspect them in tests or dump them during debugging.
$this->withQueryLog(callable): Execute a closure and return the query log from inside it.
DB::listen(callable): Register a query listener. Useful for counting queries or logging slow ones.
Model::preventLazyLoading(): Throw an exception when a relationship is lazy loaded. Set in AppServiceProvider::boot() for non-production:
if (!app()->isProduction()) { Model::preventLazyLoading(); }This makes N+1 bugs throw exceptions in tests, turning them from silent performance problems into test failures.
DB::assertExecutedCount(int $count) (Laravel 11.23+): Assert query count across the test using InteractsWithDatabase trait.
Testing memory and time: Use $this->assertLessThan() with memory_get_peak_usage() or microtime() for micro-benchmarks in unit tests. In practice, performance tests are better as dedicated benchmark suites (PHPBench).
Http::fake() for external calls: External HTTP calls can slow tests. Fake them to keep test speed predictable.
Code Example
<?php
namespace Tests\Feature;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
use App\Models\User;
use App\Models\Post;
class PerformanceTest extends TestCase
{
use RefreshDatabase;
public function test_post_index_does_not_cause_n_plus_one(): void
{
// Create 10 posts, each with a different author
Post::factory()->count(10)->create();
// Capture query count for the API request
DB::enableQueryLog();
$response = $this->getJson('/api/posts');
$response->assertOk();
$queryCount = count(DB::getQueryLog());
// Should be: 1 query for posts + 1 for authors (eager loaded)
// NOT 11 queries (1 + 10 for lazy loading each author)
$this->assertLessThanOrEqual(3, $queryCount,
"Expected at most 3 queries, got {$queryCount}. Possible N+1."
);
}
public function test_user_dashboard_query_count(): void
{
$user = User::factory()->create();
$posts = Post::factory()->count(20)->for($user)->create();
DB::flushQueryLog();
DB::enableQueryLog();
$this->actingAs($user)->get('/dashboard')->assertOk();
$queries = DB::getQueryLog();
$this->assertCount(4, $queries, implode("\n", array_column($queries, 'query')));
}
public function test_lazy_loading_throws_in_test_environment(): void
{
// Model::preventLazyLoading() is set in AppServiceProvider for non-production
$post = Post::factory()->create();
$post = Post::find($post->id); // fresh instance, no eager loading
$this->expectException(\Illuminate\Database\LazyLoadingViolationException::class);
$name = $post->author->name; // triggers lazy load → throws
}
public function test_bulk_operation_stays_within_memory_budget(): void
{
Post::factory()->count(500)->create();
$memBefore = memory_get_usage(true);
Post::chunk(100, fn($posts) => $posts->each(fn($p) => $p->touch()));
$memAfter = memory_get_usage(true);
$memUsedMb = ($memAfter - $memBefore) / 1024 / 1024;
$this->assertLessThan(50, $memUsedMb, "Used {$memUsedMb}MB — chunking should be memory-efficient");
}
}