Loading...
Loading...
Creates load and performance tests with k6, Artillery, or autocannon to validate system behavior under stress. Use when users request "load testing", "performance testing", "stress testing", "k6 setup", or "benchmark API".
npx skill4agent add monkey1sai/openai-cli load-test-builder# macOS
brew install k6
# Docker
docker pull grafana/k6// load-tests/basic.js
import http from 'k6/http';
import { check, sleep } from 'k6';
import { Rate, Trend } from 'k6/metrics';
// Custom metrics
const errorRate = new Rate('errors');
const responseTime = new Trend('response_time');
// Test configuration
export const options = {
stages: [
{ duration: '1m', target: 20 }, // Ramp up to 20 users
{ duration: '3m', target: 20 }, // Stay at 20 users
{ duration: '1m', target: 50 }, // Ramp up to 50 users
{ duration: '3m', target: 50 }, // Stay at 50 users
{ duration: '1m', target: 0 }, // Ramp down to 0
],
thresholds: {
http_req_duration: ['p(95)<500', 'p(99)<1000'],
http_req_failed: ['rate<0.01'],
errors: ['rate<0.05'],
},
};
const BASE_URL = __ENV.BASE_URL || 'http://localhost:3000';
export default function () {
// Homepage
const homeResponse = http.get(`${BASE_URL}/`);
check(homeResponse, {
'homepage status is 200': (r) => r.status === 200,
'homepage loads fast': (r) => r.timings.duration < 500,
});
responseTime.add(homeResponse.timings.duration);
errorRate.add(homeResponse.status !== 200);
sleep(1);
// API request
const apiResponse = http.get(`${BASE_URL}/api/users`);
check(apiResponse, {
'api status is 200': (r) => r.status === 200,
'api returns array': (r) => Array.isArray(JSON.parse(r.body)),
});
errorRate.add(apiResponse.status !== 200);
sleep(Math.random() * 3 + 1); // Random think time 1-4 seconds
}// load-tests/user-journey.js
import http from 'k6/http';
import { check, group, sleep } from 'k6';
import { SharedArray } from 'k6/data';
const users = new SharedArray('users', function () {
return JSON.parse(open('./data/users.json'));
});
export const options = {
scenarios: {
browse_and_buy: {
executor: 'ramping-vus',
startVUs: 0,
stages: [
{ duration: '2m', target: 100 },
{ duration: '5m', target: 100 },
{ duration: '2m', target: 0 },
],
gracefulRampDown: '30s',
},
},
thresholds: {
'group_duration{group:::Login}': ['p(95)<2000'],
'group_duration{group:::Browse Products}': ['p(95)<1000'],
'group_duration{group:::Checkout}': ['p(95)<3000'],
http_req_failed: ['rate<0.01'],
},
};
const BASE_URL = __ENV.BASE_URL || 'http://localhost:3000';
export default function () {
const user = users[Math.floor(Math.random() * users.length)];
group('Login', function () {
const loginRes = http.post(
`${BASE_URL}/api/auth/login`,
JSON.stringify({
email: user.email,
password: user.password,
}),
{
headers: { 'Content-Type': 'application/json' },
}
);
check(loginRes, {
'login successful': (r) => r.status === 200,
'has token': (r) => JSON.parse(r.body).token !== undefined,
});
if (loginRes.status !== 200) return;
const token = JSON.parse(loginRes.body).token;
group('Browse Products', function () {
const productsRes = http.get(`${BASE_URL}/api/products`, {
headers: { Authorization: `Bearer ${token}` },
});
check(productsRes, {
'products loaded': (r) => r.status === 200,
});
sleep(2);
// View product detail
const products = JSON.parse(productsRes.body);
if (products.length > 0) {
const productId = products[Math.floor(Math.random() * products.length)].id;
const productRes = http.get(`${BASE_URL}/api/products/${productId}`, {
headers: { Authorization: `Bearer ${token}` },
});
check(productRes, {
'product detail loaded': (r) => r.status === 200,
});
}
});
sleep(1);
group('Checkout', function () {
// Add to cart
const cartRes = http.post(
`${BASE_URL}/api/cart`,
JSON.stringify({ productId: '1', quantity: 1 }),
{
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
}
);
check(cartRes, {
'added to cart': (r) => r.status === 200 || r.status === 201,
});
// Checkout
const checkoutRes = http.post(
`${BASE_URL}/api/checkout`,
JSON.stringify({ paymentMethod: 'card' }),
{
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
}
);
check(checkoutRes, {
'checkout successful': (r) => r.status === 200 || r.status === 201,
});
});
});
sleep(Math.random() * 5 + 2);
}// load-tests/stress.js
import http from 'k6/http';
import { check } from 'k6';
export const options = {
stages: [
{ duration: '2m', target: 100 }, // Normal load
{ duration: '5m', target: 100 },
{ duration: '2m', target: 200 }, // High load
{ duration: '5m', target: 200 },
{ duration: '2m', target: 300 }, // Stress
{ duration: '5m', target: 300 },
{ duration: '2m', target: 400 }, // Breaking point
{ duration: '5m', target: 400 },
{ duration: '10m', target: 0 }, // Recovery
],
thresholds: {
http_req_duration: ['p(99)<1500'],
http_req_failed: ['rate<0.05'],
},
};
export default function () {
const response = http.get(`${__ENV.BASE_URL}/api/health`);
check(response, {
'status is 200': (r) => r.status === 200,
});
}// load-tests/spike.js
export const options = {
stages: [
{ duration: '10s', target: 100 }, // Quick ramp
{ duration: '1m', target: 100 }, // Normal
{ duration: '10s', target: 1000 }, // Spike!
{ duration: '3m', target: 1000 }, // Stay at spike
{ duration: '10s', target: 100 }, // Scale down
{ duration: '3m', target: 100 }, // Recovery
{ duration: '10s', target: 0 }, // Ramp down
],
};// load-tests/soak.js
export const options = {
stages: [
{ duration: '5m', target: 100 }, // Ramp up
{ duration: '8h', target: 100 }, // Sustained load for 8 hours
{ duration: '5m', target: 0 }, // Ramp down
],
thresholds: {
http_req_duration: ['p(95)<500'],
http_req_failed: ['rate<0.01'],
},
};npm install -D artillery# artillery/load-test.yml
config:
target: "http://localhost:3000"
phases:
- duration: 60
arrivalRate: 5
name: "Warm up"
- duration: 120
arrivalRate: 10
name: "Normal load"
- duration: 60
arrivalRate: 50
name: "Spike"
- duration: 60
arrivalRate: 10
name: "Cool down"
defaults:
headers:
Content-Type: "application/json"
plugins:
expect: {}
ensure:
p99: 500
maxErrorRate: 1
scenarios:
- name: "User Journey"
flow:
- get:
url: "/"
expect:
- statusCode: 200
- contentType: text/html
- think: 2
- get:
url: "/api/products"
expect:
- statusCode: 200
capture:
- json: "$[0].id"
as: "productId"
- think: 1
- get:
url: "/api/products/{{ productId }}"
expect:
- statusCode: 200
- post:
url: "/api/cart"
json:
productId: "{{ productId }}"
quantity: 1
expect:
- statusCode: 201// artillery/processor.js
module.exports = {
generateUser,
logResponse,
validateCheckout,
};
function generateUser(context, events, done) {
context.vars.email = `user${Date.now()}@example.com`;
context.vars.password = 'testpassword123';
return done();
}
function logResponse(requestParams, response, context, events, done) {
console.log(`Response: ${response.statusCode} - ${response.body}`);
return done();
}
function validateCheckout(requestParams, response, context, events, done) {
const body = JSON.parse(response.body);
if (!body.orderId) {
return done(new Error('Missing orderId in response'));
}
context.vars.orderId = body.orderId;
return done();
}# artillery/with-processor.yml
config:
target: "http://localhost:3000"
processor: "./processor.js"
phases:
- duration: 60
arrivalRate: 10
scenarios:
- name: "Checkout flow"
flow:
- function: "generateUser"
- post:
url: "/api/auth/register"
json:
email: "{{ email }}"
password: "{{ password }}"
- post:
url: "/api/checkout"
afterResponse: "validateCheckout"// load-tests/autocannon.ts
import autocannon from 'autocannon';
async function runLoadTest() {
const result = await autocannon({
url: 'http://localhost:3000/api/users',
connections: 100,
duration: 30,
pipelining: 10,
headers: {
'Content-Type': 'application/json',
},
requests: [
{
method: 'GET',
path: '/api/users',
},
{
method: 'POST',
path: '/api/users',
body: JSON.stringify({ name: 'Test', email: 'test@example.com' }),
},
],
});
console.log(autocannon.printResult(result));
// Validate results
if (result.latency.p99 > 500) {
console.error('P99 latency exceeded 500ms');
process.exit(1);
}
if (result.errors > 0) {
console.error(`Errors detected: ${result.errors}`);
process.exit(1);
}
}
runLoadTest();# .github/workflows/load-tests.yml
name: Load Tests
on:
schedule:
- cron: '0 2 * * *' # Daily at 2 AM
workflow_dispatch:
jobs:
load-test:
runs-on: ubuntu-latest
services:
app:
image: myapp:latest
ports:
- 3000:3000
env:
DATABASE_URL: postgresql://test@localhost/test
steps:
- uses: actions/checkout@v4
- name: Install k6
run: |
sudo gpg -k
sudo gpg --no-default-keyring --keyring /usr/share/keyrings/k6-archive-keyring.gpg --keyserver hkp://keyserver.ubuntu.com:80 --recv-keys C5AD17C747E3415A3642D57D77C6C491D6AC1D69
echo "deb [signed-by=/usr/share/keyrings/k6-archive-keyring.gpg] https://dl.k6.io/deb stable main" | sudo tee /etc/apt/sources.list.d/k6.list
sudo apt-get update
sudo apt-get install k6
- name: Wait for app
run: |
timeout 60 bash -c 'until curl -s http://localhost:3000/health; do sleep 1; done'
- name: Run load tests
run: k6 run --out json=results.json load-tests/basic.js
env:
BASE_URL: http://localhost:3000
- name: Upload results
uses: actions/upload-artifact@v4
with:
name: load-test-results
path: results.json
- name: Comment on PR
if: github.event_name == 'pull_request'
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
const results = JSON.parse(fs.readFileSync('results.json', 'utf8'));
// Parse and format results for PR comment// scripts/analyze-results.js
import fs from 'fs';
const results = JSON.parse(fs.readFileSync('results.json', 'utf8'));
const summary = {
totalRequests: results.metrics.http_reqs.count,
avgDuration: results.metrics.http_req_duration.avg,
p95Duration: results.metrics.http_req_duration['p(95)'],
p99Duration: results.metrics.http_req_duration['p(99)'],
errorRate: results.metrics.http_req_failed.rate,
throughput: results.metrics.http_reqs.rate,
};
console.table(summary);
// Check thresholds
const passed =
summary.p95Duration < 500 &&
summary.p99Duration < 1000 &&
summary.errorRate < 0.01;
process.exit(passed ? 0 : 1);