Testing & TDD

Performance Testing

28 min Lesson 22 of 35

Performance Testing

Performance testing ensures your application can handle expected user loads and responds quickly under various conditions. In this lesson, we'll explore load testing, benchmarking, profiling, and tools like k6 and Artillery to measure and optimize application performance.

Why Performance Testing Matters

Performance testing helps you:

  • Identify bottlenecks before they affect users
  • Ensure acceptable response times under load
  • Determine system capacity and scalability limits
  • Validate infrastructure sizing decisions
  • Prevent performance regressions with each release
  • Optimize database queries and API calls
  • Measure and improve resource utilization
Key Concept: Performance testing is not just about speed—it's about ensuring consistent, predictable behavior under realistic conditions and graceful degradation under stress.

Types of Performance Testing

Different testing types serve different purposes:

  • Load Testing: Testing system behavior under expected load conditions
  • Stress Testing: Testing beyond normal capacity to find breaking points
  • Spike Testing: Testing sudden, dramatic increases in load
  • Soak Testing: Testing sustained load over extended periods to detect memory leaks
  • Scalability Testing: Testing how the system scales with increasing load
  • Volume Testing: Testing with large amounts of data

Basic Benchmarking in PHPUnit

Start with simple timing measurements in your tests:

<?php namespace Tests\Performance; use App\Models\Product; use Illuminate\Foundation\Testing\RefreshDatabase; use Tests\TestCase; class ProductSearchPerformanceTest extends TestCase { use RefreshDatabase; public function test_search_completes_within_time_limit() { // Seed test data Product::factory()->count(1000)->create(); $startTime = microtime(true); // Perform search $results = Product::where('name', 'like', '%test%') ->with(['category', 'images']) ->paginate(20); $duration = microtime(true) - $startTime; // Assert response time is acceptable (< 100ms) $this->assertLessThan(0.1, $duration, "Search took {$duration}s, expected < 0.1s" ); // Assert results are correct $this->assertGreaterThan(0, $results->total()); } public function test_bulk_insert_performance() { $data = Product::factory()->count(1000)->make()->toArray(); $startTime = microtime(true); Product::insert($data); $duration = microtime(true) - $startTime; // Bulk insert should be fast (< 1s for 1000 records) $this->assertLessThan(1.0, $duration, "Bulk insert of 1000 records took {$duration}s" ); $this->assertEquals(1000, Product::count()); } }

Database Query Performance

Laravel's query logging helps identify slow queries:

<?php namespace Tests\Performance; use Illuminate\Support\Facades\DB; use Tests\TestCase; class QueryPerformanceTest extends TestCase { public function test_no_n_plus_one_queries() { // Enable query logging DB::enableQueryLog(); $posts = Post::with(['author', 'comments'])->limit(10)->get(); // Load all comments foreach ($posts as $post) { $commentCount = $post->comments->count(); } $queries = DB::getQueryLog(); // Should only execute 1 query (for posts with eager loading) // Not N+1 queries (1 for posts + N for comments) $this->assertLessThanOrEqual(1, count($queries), "N+1 query detected. Executed " . count($queries) . " queries" ); } public function test_query_execution_time() { DB::enableQueryLog(); $startTime = microtime(true); $results = Product::select('products.*') ->join('categories', 'categories.id', '=', 'products.category_id') ->where('categories.active', true) ->whereNull('products.deleted_at') ->orderBy('products.created_at', 'desc') ->take(100) ->get(); $duration = microtime(true) - $startTime; $queries = DB::getQueryLog(); // Log slow queries for analysis foreach ($queries as $query) { if ($query['time'] > 50) { // 50ms $this->fail("Slow query detected: {$query['query']}" . " took {$query['time']}ms"); } } $this->assertLessThan(0.1, $duration); } }

API Endpoint Performance Testing

Test HTTP endpoint response times:

<?php namespace Tests\Performance; use Tests\TestCase; class ApiPerformanceTest extends TestCase { public function test_api_response_time() { $iterations = 10; $times = []; for ($i = 0; $i < $iterations; $i++) { $startTime = microtime(true); $response = $this->getJson('/api/products?page=1&per_page=20'); $duration = microtime(true) - $startTime; $times[] = $duration; $response->assertOk(); } $averageTime = array_sum($times) / count($times); $maxTime = max($times); // Assert average response time $this->assertLessThan(0.2, $averageTime, "Average response time: {$averageTime}s" ); // Assert max response time $this->assertLessThan(0.5, $maxTime, "Max response time: {$maxTime}s" ); } public function test_concurrent_requests_performance() { // Simulate concurrent requests $responses = []; $startTime = microtime(true); // In real concurrent testing, use tools like k6 or Artillery // This is a simplified example for ($i = 0; $i < 50; $i++) { $responses[] = $this->getJson('/api/products'); } $totalTime = microtime(true) - $startTime; foreach ($responses as $response) { $response->assertOk(); } // 50 requests should complete reasonably quickly $this->assertLessThan(10.0, $totalTime, "50 sequential requests took {$totalTime}s" ); } }
Best Practice: Run performance tests in an environment that closely matches production—same database size, similar hardware, network conditions, and caching configurations.

Load Testing with k6

k6 is a modern load testing tool built for developers. Install it and create test scripts:

// install: brew install k6 (macOS) or snap install k6 (Linux) // load-test.js import http from 'k6/http'; import { check, sleep } from 'k6'; import { Rate } from 'k6/metrics'; // Custom metrics const errorRate = new Rate('errors'); // Test configuration export const options = { stages: [ { duration: '30s', target: 10 }, // Ramp up to 10 users { duration: '1m', target: 50 }, // Ramp up to 50 users { duration: '2m', target: 50 }, // Stay at 50 users { duration: '30s', target: 0 }, // Ramp down to 0 users ], thresholds: { http_req_duration: ['p(95)<500'], // 95% of requests < 500ms http_req_failed: ['rate<0.01'], // Error rate < 1% errors: ['rate<0.1'], // Custom error rate < 10% }, }; export default function () { // Test homepage let homeRes = http.get('https://example.com'); check(homeRes, { 'homepage status is 200': (r) => r.status === 200, 'homepage loads in < 200ms': (r) => r.timings.duration < 200, }); sleep(1); // Test API endpoint let apiRes = http.get('https://example.com/api/products', { headers: { 'Accept': 'application/json', }, }); const apiCheck = check(apiRes, { 'API status is 200': (r) => r.status === 200, 'API returns JSON': (r) => r.headers['Content-Type'].includes('json'), 'API response time OK': (r) => r.timings.duration < 300, }); errorRate.add(!apiCheck); sleep(2); // Test search let searchRes = http.get('https://example.com/api/products?search=laptop'); check(searchRes, { 'search returns results': (r) => r.json('data').length > 0, }); sleep(1); } // Run: k6 run load-test.js

Advanced k6 Scenarios

Create more sophisticated test scenarios:

// advanced-load-test.js import http from 'k6/http'; import { check, group } from 'k6'; export const options = { scenarios: { // Scenario 1: Constant load constant_load: { executor: 'constant-vus', vus: 20, duration: '2m', }, // Scenario 2: Spike test spike_test: { executor: 'ramping-vus', startTime: '2m', stages: [ { duration: '10s', target: 100 }, { duration: '1m', target: 100 }, { duration: '10s', target: 0 }, ], }, // Scenario 3: Stress test stress_test: { executor: 'ramping-arrival-rate', startTime: '4m', startRate: 50, timeUnit: '1s', stages: [ { duration: '2m', target: 200 }, { duration: '5m', target: 200 }, { duration: '2m', target: 0 }, ], }, }, thresholds: { 'http_req_duration{scenario:constant_load}': ['p(95)<300'], 'http_req_duration{scenario:spike_test}': ['p(95)<1000'], 'http_req_failed': ['rate<0.05'], }, }; export default function () { group('Browse Products', function () { let res = http.get('https://example.com/api/products'); check(res, { 'products loaded': (r) => r.status === 200, }); }); group('View Product Details', function () { let res = http.get('https://example.com/api/products/123'); check(res, { 'product details loaded': (r) => r.status === 200, 'product has required fields': (r) => { const body = r.json(); return body.name && body.price && body.description; }, }); }); }

Load Testing with Artillery

Artillery is another powerful load testing tool with YAML configuration:

# Install: npm install -g artillery # artillery-test.yml config: target: 'https://example.com' phases: - duration: 60 arrivalRate: 10 name: 'Warm up' - duration: 120 arrivalRate: 50 name: 'Sustained load' - duration: 60 arrivalRate: 100 name: 'Spike' http: timeout: 10 plugins: expect: {} ensure: maxErrorRate: 1 p95: 500 p99: 1000 scenarios: - name: 'Browse and search' flow: - get: url: '/' expect: - statusCode: 200 - contentType: text/html - think: 2 - get: url: '/api/products' expect: - statusCode: 200 - hasProperty: data - think: 3 - post: url: '/api/search' json: query: 'laptop' filters: category: 'electronics' expect: - statusCode: 200 - hasProperty: results - think: 5 - name: 'User authentication flow' weight: 30 flow: - post: url: '/api/login' json: email: 'test@example.com' password: 'password123' capture: - json: '$.token' as: 'authToken' - get: url: '/api/user/profile' headers: Authorization: 'Bearer {{ authToken }}' expect: - statusCode: 200 # Run: artillery run artillery-test.yml # Run with report: artillery run --output report.json artillery-test.yml # Generate HTML report: artillery report report.json
Important: Always test against non-production environments or get explicit permission before load testing production systems. Unexpected traffic can cause outages.

Profiling PHP Applications

Use Xdebug or XHProf for detailed profiling:

// Install Xdebug: pecl install xdebug // Enable profiling in php.ini: xdebug.mode=profile xdebug.output_dir=/tmp xdebug.profiler_enable_trigger=1 // Trigger profiling with URL parameter: // https://example.com/slow-page?XDEBUG_PROFILE=1 // Analyze with tools like: // - KCacheGrind (Linux) // - QCacheGrind (macOS/Windows) // - Webgrind (web-based)

Laravel Telescope for Performance Monitoring

Use Telescope to monitor application performance in development:

// Install Telescope composer require laravel/telescope --dev php artisan telescope:install php artisan migrate // Configure in config/telescope.php 'watchers' => [ Watchers\QueryWatcher::class => [ 'enabled' => env('TELESCOPE_QUERY_WATCHER', true), 'slow' => 50, // Log queries slower than 50ms ], Watchers\RequestWatcher::class => [ 'enabled' => env('TELESCOPE_REQUEST_WATCHER', true), 'size_limit' => 64, ], ], // Access at: http://localhost/telescope // View slow queries, request durations, memory usage, etc.

Database Query Optimization

Optimize slow database queries identified in tests:

// BEFORE: N+1 query problem $users = User::all(); foreach ($users as $user) { echo $user->posts->count(); // Executes query for each user } // AFTER: Eager loading $users = User::withCount('posts')->get(); foreach ($users as $user) { echo $user->posts_count; // No additional queries } // BEFORE: Loading unnecessary data $products = Product::all(); // Loads all columns, all rows // AFTER: Select only needed columns and limit rows $products = Product::select(['id', 'name', 'price']) ->where('active', true) ->limit(100) ->get(); // Use indexing for frequently queried columns Schema::table('products', function (Blueprint $table) { $table->index('category_id'); $table->index(['active', 'created_at']); $table->fullText('name'); // For search queries });

Caching for Performance

Implement caching to reduce database load:

<?php // Cache expensive queries $products = Cache::remember('products.featured', 3600, function () { return Product::with(['images', 'category']) ->where('featured', true) ->orderBy('popularity', 'desc') ->take(10) ->get(); }); // Cache API responses Route::get('/api/products', function () { return Cache::remember('api.products.' . request('page', 1), 600, function () { return Product::paginate(20); }); }); // Test cache performance public function test_cache_improves_performance() { // First call (cache miss) $start = microtime(true); $result1 = $this->getJson('/api/products'); $time1 = microtime(true) - $start; // Second call (cache hit) $start = microtime(true); $result2 = $this->getJson('/api/products'); $time2 = microtime(true) - $start; // Cached response should be significantly faster $this->assertLessThan($time1 / 2, $time2, "Cache should improve performance by at least 50%" ); }
Warning: Premature optimization is the root of all evil. Always profile and measure before optimizing. Focus on actual bottlenecks, not theoretical ones.

Memory Profiling

Monitor memory usage in tests:

<?php public function test_bulk_operation_memory_usage() { $memoryBefore = memory_get_usage(true); // Process large dataset Product::chunk(1000, function ($products) { foreach ($products as $product) { // Process product $product->update(['processed' => true]); } }); $memoryAfter = memory_get_usage(true); $memoryUsed = $memoryAfter - $memoryBefore; // Assert memory usage is reasonable (< 50MB) $this->assertLessThan(50 * 1024 * 1024, $memoryUsed, "Memory usage: " . round($memoryUsed / 1024 / 1024, 2) . "MB" ); } public function test_memory_leak_detection() { $iterations = 100; $memoryReadings = []; for ($i = 0; $i < $iterations; $i++) { // Perform operation $users = User::with('posts')->get(); // Record memory $memoryReadings[] = memory_get_usage(true); unset($users); } // Check if memory steadily increases (potential leak) $firstHalf = array_slice($memoryReadings, 0, 50); $secondHalf = array_slice($memoryReadings, 50); $avgFirst = array_sum($firstHalf) / count($firstHalf); $avgSecond = array_sum($secondHalf) / count($secondHalf); // Memory should not increase by more than 20% $this->assertLessThan($avgFirst * 1.2, $avgSecond, "Potential memory leak detected" ); }
Exercise 1: Create a k6 load test script for an e-commerce checkout flow that simulates: (1) browsing products, (2) adding to cart, (3) viewing cart, (4) initiating checkout, and (5) completing order. Configure thresholds for 95th percentile response times.
Exercise 2: Write performance tests for a data export feature that generates CSV files with 100,000 rows. Test memory usage, execution time, and ensure it doesn't timeout. Optimize using chunking if needed.
Exercise 3: Profile a slow dashboard page using Telescope or Xdebug. Identify the top 3 performance bottlenecks (slow queries, N+1 problems, missing indexes) and implement fixes. Verify improvements with before/after benchmarks.

Summary

In this lesson, we've covered comprehensive performance testing strategies:

  • Understanding different types of performance testing (load, stress, spike, soak)
  • Basic benchmarking with PHPUnit and timing measurements
  • Identifying N+1 query problems and optimizing database queries
  • Load testing with k6 and Artillery for realistic user scenarios
  • Profiling PHP applications with Xdebug and Telescope
  • Implementing caching strategies for improved performance
  • Memory profiling and leak detection
  • Setting performance thresholds and SLAs

Performance testing is an ongoing process. Integrate these tests into your CI/CD pipeline to catch regressions early and ensure your application remains fast and responsive as it grows.