This walkthrough builds the ShopFast test suite step by step. Each step is a discrete, testable unit. Complete each one before moving to the next. The full suite is about 400 lines of code across 8 files — achievable in a focused afternoon.
Build progression
Step 1 of 5
Step 1–2: Foundation
Project structure and shared helpers. Create the directory layout, write the auth helper (login once, share token), and define custom metric instances. This is the scaffold everything else hangs on.
Step 1: Project structure and helpers
Create the directory layout and the shared helpers library.
lib/metrics.js — define custom metrics once, import everywhere:
import { Counter, Rate, Trend } from 'k6/metrics';
export const ordersCreated = new Counter('orders_created');
export const checkoutCompletionRate = new Rate('checkout_completion_rate');
export const checkoutFlowMs = new Trend('checkout_flow_ms');
export const cartItemsAdded = new Counter('cart_items_added');lib/auth.js — login helper for setup():
import http from 'k6/http';
import { check, fail } from 'k6';
const BASE_URL = __ENV.BASE_URL || 'https://jsonplaceholder.typicode.com';
export function login() {
// JSONPlaceholder doesn't have a real login — simulate one
const res = http.post(`${BASE_URL}/posts`,
JSON.stringify({ title: 'auth', body: 'login', userId: 1 }),
{ headers: { 'Content-Type': 'application/json' } }
);
if (!check(res, { 'auth succeeded': (r) => r.status === 201 })) {
fail('Login failed in setup — aborting test run');
}
return `simulated-token-${res.json('id')}`;
}
export { BASE_URL };lib/http-helpers.js — tagged request wrappers:
import http from 'k6/http';
export function get(url, tagName, token) {
return http.get(url, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
tags: { name: tagName },
});
}
export function post(url, tagName, body, token) {
return http.post(url,
JSON.stringify(body),
{
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
tags: { name: tagName },
}
);
}Step 2: Test data
Create small data files. In a real project these would have thousands of entries — for the capstone, 20 is enough to verify the pattern.
data/users.json:
[
{ "id": 1, "email": "user1@shopfast.test", "role": "customer" },
{ "id": 2, "email": "user2@shopfast.test", "role": "customer" },
{ "id": 3, "email": "user3@shopfast.test", "role": "customer" }
]data/products.json:
[
{ "id": 1, "name": "Laptop", "price": 999 },
{ "id": 2, "name": "Keyboard", "price": 79 },
{ "id": 3, "name": "Monitor", "price": 349 }
]Step 3: Smoke test
tests/smoke.js — one VU, 60 seconds, every endpoint visited once:
import { check } from 'k6';
import { get, post, BASE_URL } from '../lib/http-helpers.js';
import { htmlReport } from 'https://raw.githubusercontent.com/benc-uk/k6-reporter/main/dist/bundle.js';
import { textSummary } from 'https://jslib.k6.io/k6-summary/0.0.2/index.js';
export const options = {
vus: 1,
duration: '60s',
thresholds: {
http_req_duration: ['p(95)<1000'],
http_req_failed: ['rate<0.01'],
},
};
export default function () {
check(get(`${BASE_URL}/posts`, 'ListProducts'),
{ 'products listed': (r) => r.status === 200 });
check(get(`${BASE_URL}/posts/1`, 'GetProduct'),
{ 'product detail ok': (r) => r.status === 200 });
check(get(`${BASE_URL}/users/1`, 'GetProfile'),
{ 'profile ok': (r) => r.status === 200 });
check(post(`${BASE_URL}/posts`, 'CreateOrder',
{ title: 'order', body: 'checkout', userId: 1 }),
{ 'order created': (r) => r.status === 201 });
}
export function handleSummary(data) {
return {
'results/smoke-report.html': htmlReport(data, { title: 'ShopFast — Smoke Test' }),
stdout: textSummary(data, { indent: ' ', enableColors: true }),
};
}Step 4: Load test with scenarios
tests/load.js — mixed workload with proportional traffic:
import { check, sleep } from 'k6';
import { SharedArray } from 'k6/data';
import { get, post, BASE_URL } from '../lib/http-helpers.js';
import { ordersCreated, checkoutCompletionRate, checkoutFlowMs } from '../lib/metrics.js';
import { login } from '../lib/auth.js';
import { htmlReport } from 'https://raw.githubusercontent.com/benc-uk/k6-reporter/main/dist/bundle.js';
import { textSummary } from 'https://jslib.k6.io/k6-summary/0.0.2/index.js';
const users = new SharedArray('users', () => JSON.parse(open('../data/users.json')));
const products = new SharedArray('products', () => JSON.parse(open('../data/products.json')));
export const options = {
scenarios: {
browse: {
executor: 'ramping-vus',
exec: 'browseProducts',
startVUs: 0,
stages: [
{ duration: '5m', target: 60 },
{ duration: '20m', target: 60 },
{ duration: '5m', target: 0 },
],
tags: { scenario: 'browse' },
},
checkout: {
executor: 'ramping-vus',
exec: 'checkoutFlow',
startVUs: 0,
stages: [
{ duration: '5m', target: 10 },
{ duration: '20m', target: 10 },
{ duration: '5m', target: 0 },
],
tags: { scenario: 'checkout' },
},
},
thresholds: {
'http_req_duration{scenario:browse}': ['p(95)<500'],
'http_req_duration{scenario:checkout}': ['p(95)<1000'],
'http_req_failed': ['rate<0.001'],
'checkout_completion_rate': ['rate>0.99'],
'checkout_flow_ms': ['p(95)<3000'],
},
};
export function setup() {
return { token: login() };
}
export function browseProducts(data) {
const product = products[Math.floor(Math.random() * products.length)];
check(get(`${BASE_URL}/posts`, 'ListProducts', data.token),
{ 'list ok': (r) => r.status === 200 });
check(get(`${BASE_URL}/posts/${product.id}`, 'GetProduct', data.token),
{ 'detail ok': (r) => r.status === 200 });
sleep(Math.random() * 2 + 1);
}
export function checkoutFlow(data) {
const flowStart = Date.now();
const cartRes = post(`${BASE_URL}/posts`, 'AddToCart',
{ title: 'cart', body: 'add item', userId: (__VU - 1) % users.length + 1 },
data.token
);
check(cartRes, { 'item added': (r) => r.status === 201 });
const orderRes = post(`${BASE_URL}/posts`, 'CreateOrder',
{ title: 'order', body: 'checkout', userId: (__VU - 1) % users.length + 1 },
data.token
);
const orderOk = check(orderRes, { 'order created': (r) => r.status === 201 });
checkoutCompletionRate.add(orderOk);
checkoutFlowMs.add(Date.now() - flowStart);
if (orderOk) ordersCreated.add(1);
sleep(Math.random() * 3 + 2);
}
export function handleSummary(data) {
return {
'results/load-report.html': htmlReport(data, { title: 'ShopFast — Load Test' }),
stdout: textSummary(data, { indent: ' ', enableColors: true }),
};
}Step 5: Stress test
tests/stress.js — incremental ramp to find the breaking point:
import { check, sleep } from 'k6';
import { get, post, BASE_URL } from '../lib/http-helpers.js';
export const options = {
stages: [
{ duration: '3m', target: 50 },
{ duration: '5m', target: 50 },
{ duration: '3m', target: 150 },
{ duration: '5m', target: 150 },
{ duration: '3m', target: 300 },
{ duration: '5m', target: 300 },
{ duration: '3m', target: 0 },
],
thresholds: {
http_req_failed: [{
threshold: 'rate<0.30',
abortOnFail: true,
delayAbortEval: '2m',
}],
},
};
export default function () {
check(get(`${BASE_URL}/posts`, 'StressListProducts'),
{ 'ok': (r) => r.status === 200 });
sleep(1);
}Step 6: Spike test
tests/spike.js — sudden 10× surge:
import { check, sleep } from 'k6';
import { get, BASE_URL } from '../lib/http-helpers.js';
export const options = {
stages: [
{ duration: '2m', target: 10 },
{ duration: '30s', target: 100 },
{ duration: '3m', target: 100 },
{ duration: '30s', target: 10 },
{ duration: '3m', target: 10 },
{ duration: '1m', target: 0 },
],
};
export default function () {
check(get(`${BASE_URL}/posts`, 'SpikeListProducts'),
{ 'ok': (r) => r.status === 200 });
sleep(1);
}Step 7: Soak test
tests/soak.js — 4-hour stability check (reduced for practice):
import { check, sleep } from 'k6';
import { Trend } from 'k6/metrics';
import { get, BASE_URL } from '../lib/http-helpers.js';
const iterDuration = new Trend('iter_duration_ms');
export const options = {
stages: [
{ duration: '5m', target: 20 },
{ duration: '4h', target: 20 }, // reduce to '10m' for practice runs
{ duration: '5m', target: 0 },
],
thresholds: {
http_req_duration: ['p(95)<1000'],
iter_duration_ms: ['p(95)<2000'],
},
};
export default function () {
const start = Date.now();
check(get(`${BASE_URL}/posts`, 'SoakProducts'),
{ 'ok': (r) => r.status === 200 });
sleep(Math.random() * 2 + 1);
iterDuration.add(Date.now() - start);
}Step 8: GitHub Actions workflow
.github/workflows/performance.yml:
name: Performance Tests
on:
push:
branches: [main, develop]
pull_request:
schedule:
- cron: '0 2 * * *'
workflow_dispatch:
jobs:
smoke:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Smoke test
uses: grafana/k6-action@v0.3.1
with:
filename: tests/smoke.js
env:
BASE_URL: ${{ secrets.STAGING_URL || 'https://jsonplaceholder.typicode.com' }}
- name: Upload smoke report
if: always()
uses: actions/upload-artifact@v4
with:
name: smoke-report-${{ github.sha }}
path: results/smoke-report.html
load-test:
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main' || github.event_name == 'schedule'
needs: smoke
steps:
- uses: actions/checkout@v4
- name: Load test
uses: grafana/k6-action@v0.3.1
with:
filename: tests/load.js
env:
BASE_URL: ${{ secrets.STAGING_URL || 'https://jsonplaceholder.typicode.com' }}
- name: Upload load report
if: always()
uses: actions/upload-artifact@v4
with:
name: load-report-${{ github.sha }}
path: results/load-report.html
retention-days: 90This workflow runs the smoke test on every PR and push, and the load test only on merges to main or on a schedule.
Establishing the baseline
After the load test passes once under normal conditions:
k6 run --summary-export=baselines/load-test-baseline.json tests/load.js
git add baselines/load-test-baseline.json
git commit -m "perf: establish load test baseline"The committed baseline is the reference point for future regression comparisons. Update it intentionally when performance genuinely changes.