0

Browser testing with Laravel Dusk

Advanced5 min read·lv-26-008

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:

php
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
<?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");
    }
}