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.