Guided Walkthrough

12 min read

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: 90

This 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.

// tip to track lessons you complete and pick up where you left off across devices.