Performance Testing Microservices: Isolation and Dependencies
Strategies for performance testing microservices architectures, including service isolation, dependency mocking, and distributed tracing.
Mark
Performance Testing Expert
Performance testing microservices is fundamentally different from testing monolithic applications. Instead of a single system with predictable behaviour, you’re testing a network of interdependent services where performance issues can cascade unpredictably.
The Microservices Testing Challenge
A typical request might traverse multiple services:
Client → API Gateway → Auth Service → Product Service → Inventory Service → Database
↓
Pricing Service → Cache
Each hop adds latency, and any service can become a bottleneck. Testing requires understanding both individual service performance and system-wide behaviour.
Service Isolation Testing
Start by testing each service in isolation to establish baselines:
// k6 test for Product Service in isolation
import http from 'k6/http';
import { check } from 'k6';
export const options = {
scenarios: {
product_service: {
executor: 'ramping-vus',
startVUs: 0,
stages: [
{ duration: '2m', target: 50 },
{ duration: '5m', target: 50 },
{ duration: '2m', target: 0 },
],
},
},
thresholds: {
http_req_duration: ['p(95)<100'], // Service-specific SLA
},
};
const PRODUCT_SERVICE = __ENV.PRODUCT_SERVICE_URL;
export default function () {
const productId = Math.floor(Math.random() * 1000) + 1;
const res = http.get(`${PRODUCT_SERVICE}/products/${productId}`);
check(res, {
'status is 200': (r) => r.status === 200,
'has product data': (r) => r.json().id !== undefined,
});
}
Document baseline metrics for each service:
| Service | p50 | p95 | p99 | Max RPS |
|---|---|---|---|---|
| Auth | 15ms | 45ms | 80ms | 5000 |
| Product | 25ms | 60ms | 120ms | 3000 |
| Inventory | 20ms | 50ms | 100ms | 4000 |
| Pricing | 10ms | 30ms | 60ms | 8000 |
Mocking Dependencies
For isolated testing, mock downstream services:
# docker-compose.yml for isolated testing
version: '3'
services:
product-service:
build: ./product-service
environment:
- INVENTORY_URL=http://mock-inventory:8080
- PRICING_URL=http://mock-pricing:8080
mock-inventory:
image: wiremock/wiremock
volumes:
- ./mocks/inventory:/home/wiremock
mock-pricing:
image: wiremock/wiremock
volumes:
- ./mocks/pricing:/home/wiremock
WireMock stub for inventory service:
{
"request": {
"method": "GET",
"urlPathPattern": "/inventory/.*"
},
"response": {
"status": 200,
"jsonBody": {
"available": true,
"quantity": 100
},
"fixedDelayMilliseconds": 20
}
}
The fixed delay simulates realistic downstream latency.
End-to-End Testing
After isolated testing, test the complete request path:
// End-to-end user journey test
import http from 'k6/http';
import { check, group, sleep } from 'k6';
const BASE_URL = __ENV.API_GATEWAY_URL;
export default function () {
let token;
group('Authentication', () => {
const loginRes = http.post(`${BASE_URL}/auth/login`, JSON.stringify({
username: 'testuser',
password: 'testpass',
}), { headers: { 'Content-Type': 'application/json' } });
check(loginRes, { 'login successful': (r) => r.status === 200 });
token = loginRes.json('token');
});
const headers = {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
};
group('Browse Products', () => {
const products = http.get(`${BASE_URL}/products`, { headers });
check(products, { 'products loaded': (r) => r.status === 200 });
});
group('Check Inventory', () => {
const inventory = http.get(`${BASE_URL}/products/123/availability`, { headers });
check(inventory, { 'inventory checked': (r) => r.status === 200 });
});
group('Create Order', () => {
const order = http.post(`${BASE_URL}/orders`, JSON.stringify({
productId: 123,
quantity: 1,
}), { headers });
check(order, { 'order created': (r) => r.status === 201 });
});
sleep(1);
}
Distributed Tracing
Correlate performance data across services using distributed tracing:
import http from 'k6/http';
import { uuidv4 } from 'https://jslib.k6.io/k6-utils/1.4.0/index.js';
export default function () {
const traceId = uuidv4();
const headers = {
'X-Trace-ID': traceId,
'X-Span-ID': uuidv4(),
};
console.log(`Trace ID: ${traceId}`);
const res = http.get(`${__ENV.API_URL}/products/123`, { headers });
// Log trace ID with response time for correlation
console.log(`${traceId}: ${res.timings.duration}ms`);
}
Query your tracing system (Jaeger, Zipkin) with the trace ID to see where time was spent:
Auth Service: ████░░░░░░░░░░░░░░░░ 45ms
Product Service: ░░░░████████░░░░░░░░ 120ms
Inventory Check: ░░░░░░░░░░░░████░░░░ 60ms
Pricing Lookup: ░░░░░░░░░░░░░░░░██░░ 30ms
Total: ████████████████████ 255ms
Circuit Breaker Testing
Test how services behave when dependencies fail:
// Test circuit breaker behaviour
import http from 'k6/http';
import { check } from 'k6';
export const options = {
scenarios: {
normal_load: {
executor: 'constant-vus',
vus: 20,
duration: '2m',
},
dependency_failure: {
executor: 'constant-vus',
vus: 20,
duration: '2m',
startTime: '2m', // Start after normal load
env: { FAIL_INVENTORY: 'true' },
},
recovery: {
executor: 'constant-vus',
vus: 20,
duration: '2m',
startTime: '4m', // After dependency restored
},
},
};
Verify:
- Response times don’t spike dramatically when dependencies fail
- Error responses are returned quickly (circuit open)
- Service recovers when dependency returns
Load Distribution Patterns
Microservices often have uneven load distribution. Test realistic patterns:
export const options = {
scenarios: {
read_heavy: {
executor: 'constant-vus',
vus: 80,
duration: '10m',
exec: 'readOperations',
},
write_light: {
executor: 'constant-vus',
vus: 20,
duration: '10m',
exec: 'writeOperations',
},
},
};
export function readOperations() {
http.get(`${BASE_URL}/products`);
}
export function writeOperations() {
http.post(`${BASE_URL}/orders`, JSON.stringify({ productId: 1 }));
}
Latency Aggregation
In microservices, latencies compound. A request touching 5 services, each with 50ms p95, won’t have a 250ms p95 for the total request - it will be higher due to variance:
// Measure and report per-service contribution
import http from 'k6/http';
import { Trend } from 'k6/metrics';
const authLatency = new Trend('auth_latency');
const productLatency = new Trend('product_latency');
const inventoryLatency = new Trend('inventory_latency');
const totalLatency = new Trend('total_latency');
export default function () {
const start = Date.now();
const auth = http.post(`${BASE_URL}/auth/token`);
authLatency.add(auth.timings.duration);
const product = http.get(`${BASE_URL}/products/123`);
productLatency.add(product.timings.duration);
const inventory = http.get(`${BASE_URL}/inventory/123`);
inventoryLatency.add(inventory.timings.duration);
totalLatency.add(Date.now() - start);
}
Database Per Service
When each service has its own database, test database performance in context:
// Test service with realistic database load
export const options = {
scenarios: {
mixed_queries: {
executor: 'ramping-vus',
stages: [
{ duration: '5m', target: 100 },
{ duration: '10m', target: 100 },
{ duration: '5m', target: 0 },
],
},
},
};
export default function () {
// Simple read (cached)
http.get(`${BASE_URL}/products/popular`);
// Complex query (database hit)
http.get(`${BASE_URL}/products/search?q=laptop&sort=price&filter=instock`);
// Write operation
if (Math.random() < 0.1) { // 10% writes
http.post(`${BASE_URL}/products/123/reviews`, JSON.stringify({
rating: 5,
comment: 'Great product',
}));
}
}
Recommendations
- Test services in isolation first to establish baselines
- Mock dependencies with realistic latency for isolated tests
- Use distributed tracing to identify bottlenecks across services
- Test failure scenarios - circuit breakers, timeouts, retries
- Monitor all services during end-to-end tests
- Document SLAs for each service and the system as a whole
Microservices performance testing requires more planning than monolith testing, but provides better insight into system behaviour and helps identify issues before they cascade into outages.
Tags: