TestForge
Log in
Medium difficulty~70 min read

Grafana k6

Developer-first, script-in-JavaScript load testing with thresholds as pass/fail gates.

What is k6

k6 is a modern, open-source load testing tool from Grafana. You write tests as JavaScript, but they run on a fast Go engine, so a single machine can drive far more virtual users than heavier tools. Thresholds turn a load test into a pass/fail check, which makes k6 a natural CI performance gate.

  • Scripts are plain JavaScript (ES modules) — versionable and reviewable like any code.
  • Efficient Go runtime: high concurrency with modest resources.
  • Thresholds: define SLOs (e.g. p95 < 500ms, error rate < 1%) that fail the run when breached.
  • Scenarios & executors: model ramping VUs, constant arrival rate, spikes, and soak tests declaratively.

Target the TestForge gateway

Hit the public path http://localhost:3000/api — e.g. GET /api/catalog/products — the same surface the platform’s perf-sim tool exercises.

Installation

bash
# macOS
brew install k6
# Windows
winget install k6 --source winget   # or: choco install k6
# Linux / Docker
docker run --rm -i grafana/k6 run - <script.js

Your first test

A k6 script exports a default function (the code each virtual user runs) and optional options. check() records pass/fail without stopping the test; sleep() adds think-time.

catalog.js
import http from 'k6/http';
import { check, sleep } from 'k6';

export const options = {
  vus: 20,          // 20 virtual users
  duration: '30s',  // for 30 seconds
};

export default function () {
  const res = http.get('http://localhost:3000/api/catalog/products?limit=5');

  check(res, {
    'status is 200': (r) => r.status === 200,
    'has products': (r) => r.json('data').length > 0,
  });

  sleep(1); // think-time between iterations
}
bash
k6 run catalog.js

Thresholds — the pass/fail gate

Thresholds are what make k6 CI-friendly: if a metric breaches its limit, k6 exits non-zero and the pipeline fails. Define them against built-in metrics like http_req_duration and http_req_failed.

javascript
export const options = {
  vus: 50,
  duration: '1m',
  thresholds: {
    http_req_failed: ['rate<0.01'],                 // < 1% errors
    http_req_duration: ['p(95)<500', 'p(99)<1000'], // p95 < 500ms, p99 < 1s
  },
};

Ramping load with stages

Instead of a fixed vus count, use stages to ramp up, hold, and ramp down — a realistic load profile.

javascript
export const options = {
  stages: [
    { duration: '30s', target: 50 },  // ramp up to 50 VUs
    { duration: '1m', target: 50 },   // stay at 50
    { duration: '20s', target: 100 }, // spike to 100
    { duration: '30s', target: 0 },   // ramp down
  ],
  thresholds: { http_req_duration: ['p(95)<800'] },
};

Authenticated flows and correlation

Log in to get a JWT, then send it on protected requests. Extract dynamic values (tokens, ids) from responses rather than hard-coding them.

cart.js
import http from 'k6/http';
import { check } from 'k6';

const BASE = 'http://localhost:3000/api';
const JSON_HEADERS = { 'Content-Type': 'application/json' };

export const options = { vus: 10, duration: '30s' };

export default function () {
  // 1) Login
  const login = http.post(
    BASE + '/auth/login',
    JSON.stringify({ email: 'customer@testforge.dev', password: 'Password123!' }),
    { headers: JSON_HEADERS }
  );
  check(login, { 'logged in': (r) => r.status === 200 });
  const token = login.json('accessToken');

  // 2) Read a product id
  const products = http.get(BASE + '/catalog/products?limit=1');
  const productId = products.json('data')[0].id;

  // 3) Add to cart (authenticated)
  const add = http.post(
    BASE + '/cart/items',
    JSON.stringify({ productId: productId, quantity: 1 }),
    { headers: Object.assign({}, JSON_HEADERS, { Authorization: 'Bearer ' + token }) }
  );
  check(add, { 'added to cart': (r) => r.status === 201 });
}

Scenarios and executors

Scenarios let you run multiple workloads with different executors in one test — for example a steady browse load plus a spiky checkout load. constant-arrival-rate is often more realistic than fixed VUs because it targets a request rate regardless of latency.

javascript
export const options = {
  scenarios: {
    browse: {
      executor: 'constant-arrival-rate',
      rate: 50, timeUnit: '1s', duration: '2m',
      preAllocatedVUs: 50,
      exec: 'browse',
    },
    checkout: {
      executor: 'ramping-vus',
      startVUs: 0,
      stages: [{ duration: '1m', target: 10 }, { duration: '1m', target: 0 }],
      exec: 'checkout',
    },
  },
};

export function browse() { /* ... */ }
export function checkout() { /* ... */ }

Custom metrics and grouping

javascript
import { Trend, Rate } from 'k6/metrics';
import { group } from 'k6';

const loginTime = new Trend('login_duration', true);
const businessErrors = new Rate('business_errors');

export default function () {
  group('login', function () {
    const res = http.post(/* ... */);
    loginTime.add(res.timings.duration);
    businessErrors.add(res.status !== 200);
  });
}

Running in CI

Because thresholds set the exit code, wiring k6 into CI is trivial — a breached SLO fails the job.

.github/workflows/k6.yml
name: k6
on: [workflow_dispatch]
jobs:
  load:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: grafana/setup-k6-action@v1
      - run: k6 run --summary-export=summary.json catalog.js
      - uses: actions/upload-artifact@v4
        with: { name: k6-summary, path: summary.json }

Best practices

  • Define thresholds as explicit SLOs so the test has a clear pass/fail meaning.
  • Focus on p95/p99 and error rate, not averages.
  • Correlate dynamic values (tokens, ids) — never replay a single stale token across VUs.
  • Add sleep()/think-time so virtual users are realistic, not a tight loop.
  • Start from a baseline, then ramp to find the breaking point; use constant-arrival-rate for rate-based SLOs.
  • Keep scripts in version control and run them in CI on demand or on a schedule.

Scenarios and executors — modelling load shape

The options.scenarios config is the most powerful way to describe load in k6. Each named scenario picks an executor — the strategy that decides how and when iterations start — and you can run several scenarios in parallel to model a realistic mix of traffic against http://localhost:3000/api.

Executors split into two mental models. VU-based (closed) executors like ramping-vus and constant-vus fix the number of virtual users; each VU loops as fast as it can, so throughput drops when the system slows down. Arrival-rate (open) executors like constant-arrival-rate and ramping-arrival-rate fix a request rate and spin up VUs as needed to hold it, so a slow backend produces a queue instead of masking the problem. Arrival-rate models are usually closer to how real users and upstream systems generate traffic.

  • ramping-vus — ramp VUs up/down through stages; the classic closed-model load profile.
  • constant-vus — a fixed number of VUs for a fixed duration.
  • constant-arrival-rate — hold a steady iteration rate; add VUs to keep the rate under latency.
  • ramping-arrival-rate — ramp the target rate itself up and down over stages.
  • per-vu-iterations / shared-iterations — run a fixed number of iterations rather than a duration.
scenarios.js
import http from 'k6/http';
import { check, sleep } from 'k6';

const BASE = 'http://localhost:3000/api';

export const options = {
  scenarios: {
    // Closed model: ramp VUs through stages
    browse_ramp: {
      executor: 'ramping-vus',
      startVUs: 0,
      stages: [
        { duration: '30s', target: 50 }, // ramp up to 50 VUs
        { duration: '1m', target: 50 },  // hold
        { duration: '20s', target: 0 },  // ramp down
      ],
      exec: 'browse',
    },
    // Open model: hold 100 requests/sec regardless of latency
    checkout_rate: {
      executor: 'constant-arrival-rate',
      rate: 100,
      timeUnit: '1s',
      duration: '2m',
      preAllocatedVUs: 50, // VUs to start with
      maxVUs: 200,         // ceiling if latency rises
      exec: 'checkout',
      startTime: '30s',    // begin after the browse ramp warms up
    },
  },
};

export function browse() {
  const res = http.get(BASE + '/catalog/products?limit=5');
  check(res, { 'browse 200': (r) => r.status === 200 });
  sleep(1);
}

export function checkout() {
  const res = http.get(BASE + '/catalog/products?limit=1');
  check(res, { 'checkout 200': (r) => r.status === 200 });
}

Prefer arrival-rate for SLO tests

If your SLO is stated as a request rate ("handle 100 req/s at p95 < 500ms"), use an arrival-rate executor. A VU-based test silently reduces throughput when the system slows, hiding exactly the failure you are trying to measure.

Thresholds and checks — assertions vs pass/fail gates

These two are easy to confuse. A check() is a per-response assertion: it records whether a condition held for that response and shows up as a pass/success rate in the summary, but a failed check does NOT stop the iteration and does NOT fail the run — k6 still exits zero. A threshold is a pass/fail SLO evaluated against a metric across the whole test; if it is breached, k6 exits non-zero, which is what fails a CI job.

The idiomatic pattern is to feed checks into a threshold so that failed assertions actually break the build. You can also define custom metrics — Trend (a distribution you take percentiles of), Counter (a running total), Rate (a percentage of true/false), and Gauge (the last value seen) — and put thresholds on them too.

thresholds.js
import http from 'k6/http';
import { check } from 'k6';
import { Trend, Counter, Rate } from 'k6/metrics';

const BASE = 'http://localhost:3000/api';

const loginDuration = new Trend('login_duration', true); // true => track time
const catalogErrors = new Counter('catalog_errors');
const businessOk = new Rate('business_ok');

export const options = {
  vus: 50,
  duration: '1m',
  thresholds: {
    // built-in metrics
    http_req_failed: ['rate<0.01'],                 // < 1% transport errors
    http_req_duration: ['p(95)<500', 'p(99)<1000'], // latency SLO
    // custom metrics
    login_duration: ['p(95)<800'],
    catalog_errors: ['count<10'],
    business_ok: ['rate>0.99'],
    // tie checks to a threshold so failed checks fail the run
    checks: ['rate>0.99'],
  },
};

export default function () {
  const login = http.post(
    BASE + '/auth/login',
    JSON.stringify({ email: 'customer@testforge.dev', password: 'Password123!' }),
    { headers: { 'Content-Type': 'application/json' } }
  );
  loginDuration.add(login.timings.duration);
  const loggedIn = check(login, { 'logged in': (r) => r.status === 200 });
  businessOk.add(loggedIn);

  const token = login.json('accessToken');
  const catalog = http.get(BASE + '/catalog', {
    headers: { Authorization: 'Bearer ' + token },
  });
  const ok = check(catalog, { 'catalog 200': (r) => r.status === 200 });
  if (!ok) catalogErrors.add(1);
}

A green summary is not a passing test

Checks that fail still leave the exit code at 0. If you rely only on checks in CI, a run full of failed assertions is reported as success. Always back an SLO with a threshold — the threshold is the gate.

Data parameterization and CI

Real tests need varied input — different users, products, and search terms — not one hard-coded value replayed by every VU. SharedArray loads a dataset once and shares a single read-only copy across all VUs, instead of every VU parsing its own copy and blowing up memory. Combine it with __VU / __ITER and environment variables so the same script runs against any target with any credentials.

data-driven.js
import http from 'k6/http';
import { check } from 'k6';
import { SharedArray } from 'k6/data';

// Parsed ONCE, shared read-only across every VU
const users = new SharedArray('users', function () {
  return JSON.parse(open('./users.json')); // [{ email, password }, ...]
});

// Configurable from the command line: k6 run -e BASE_URL=... -e VUS=100
const BASE = __ENV.BASE_URL || 'http://localhost:3000/api';

export const options = {
  vus: Number(__ENV.VUS) || 20,
  duration: __ENV.DURATION || '1m',
  thresholds: { http_req_duration: ['p(95)<500'] },
};

export default function () {
  // Pick a distinct user per VU/iteration
  const user = users[(__VU + __ITER) % users.length];

  const login = http.post(
    BASE + '/auth/login',
    JSON.stringify({ email: user.email, password: user.password }),
    { headers: { 'Content-Type': 'application/json' } }
  );
  check(login, { 'logged in': (r) => r.status === 200 });

  const token = login.json('accessToken');
  const catalog = http.get(BASE + '/catalog', {
    headers: { Authorization: 'Bearer ' + token },
  });
  check(catalog, { 'catalog 200': (r) => r.status === 200 });
}

In CI, thresholds already set the exit code, so a breached SLO fails the job with no extra scripting. Use --out to stream or export results — a JSON summary as an artifact, or a live feed to Prometheus/InfluxDB for dashboards.

bash
# Parameterize from the environment
k6 run -e BASE_URL=http://localhost:3000/api -e VUS=100 data-driven.js

# Export a machine-readable summary as a CI artifact
k6 run --summary-export=summary.json data-driven.js

# Stream raw results to a backend for live dashboards
k6 run --out json=results.json data-driven.js
k6 run --out experimental-prometheus-rw data-driven.js

Interview questions & answers

What is the difference between a VU and an iteration in k6?

A VU (virtual user) is a concurrent execution context — one simulated user running your script. An iteration is one complete run of the exported default function (or the exec function for a scenario). A single VU performs many iterations back-to-back over the test duration, so total iterations depend on both the number of VUs and how long each iteration takes.

What are executors and scenarios, and why would you use scenarios?

An executor is the strategy that controls how iterations are scheduled — for example ramping-vus, constant-vus, or constant-arrival-rate. A scenario is a named block in options.scenarios that pairs an executor with its config and an exec function. Scenarios let you run several different workloads in parallel — say a steady browse load plus a spiky checkout load — each with its own executor, timing, and even startTime, all in one test.

What is the difference between a check and a threshold, and which one fails the test?

A check is a per-response assertion that records a pass/fail rate but never stops the iteration or fails the run. A threshold is a pass/fail condition on a metric across the whole test, and breaching it makes k6 exit non-zero. So the threshold is what fails the test and the CI job; a check on its own does not. The common pattern is to add a threshold on the built-in checks metric so failed checks actually break the build.

What custom metric types does k6 provide, and when would you use each?

k6 has four: Trend, Counter, Rate, and Gauge. A Trend collects a series of values you can take percentiles/min/max from, ideal for timing a specific step like login duration. A Counter is a cumulative total, good for counting business errors or events. A Rate tracks the percentage of non-zero/true values, useful for a success ratio. A Gauge keeps only the last value added, handy for a point-in-time reading like a queue depth.

How do ramping stages work?

Stages are an array of { duration, target } steps used by ramping executors (ramping-vus or the top-level stages shortcut). k6 linearly interpolates the VU count — or the arrival rate, for ramping-arrival-rate — from the previous target to the current one over each stage duration. This lets you describe a full load profile: ramp up, hold at a plateau, spike, then ramp back down to zero.

Why do checks not fail a run while thresholds do?

By design, checks are diagnostic assertions meant to tell you which responses were valid without aborting the test — you usually want the load to keep running even when some responses are wrong so you can see the full picture. Thresholds are the deliberate pass/fail gate: they aggregate a metric over the whole run and set the process exit code. Separating the two lets you observe failures during the test but still get a single authoritative pass/fail verdict at the end.

What does sleep() do, and what is the difference between an open and a closed model?

sleep() pauses a VU to simulate think-time between actions, which keeps VUs from hammering the server in a tight loop and makes pacing more realistic. A closed model (VU-based executors) fixes the number of VUs, so throughput falls when the system slows down. An open model (arrival-rate executors) fixes the request rate and adds VUs as needed to sustain it, so a slow backend produces a growing queue instead of quietly reducing load — which is usually closer to real-world traffic.

How do you parameterize test data across VUs, and why use SharedArray?

You load data with SharedArray, which parses the source once and gives every VU a single shared, read-only copy rather than each VU parsing and holding its own. Without it, thousands of VUs each duplicating a large dataset would exhaust memory. You then index into it per VU/iteration using __VU and __ITER so each virtual user drives distinct input like a different account or product.

How do you interpret k6 output — what do p95/p99, http_req_duration, and iterations/s tell you?

http_req_duration is the end-to-end request time, and p95/p99 are the 95th/99th percentile latencies — the experience of the slowest 5% and 1% of requests, which matter far more than the average for user-facing SLOs. iterations/s is your effective throughput, i.e. how many full script runs completed per second. Read them together: rising p95/p99 while iterations/s plateaus or drops is a classic sign the system has hit its saturation point.

How does k6 compare to JMeter — what are the trade-offs?

k6 tests are code in JavaScript, so they are versionable, reviewable, and easy to wire into CI, and its Go engine drives high concurrency from modest hardware with thresholds as a built-in pass/fail gate. JMeter is GUI-driven with a huge plugin ecosystem and broad protocol support, which some teams without a coding culture prefer, but it is heavier per thread and its XML test plans are harder to diff and review. In short: k6 favours developer workflow, efficiency, and CI; JMeter favours GUI authoring and protocol breadth.

Where to go next

  • Docs: grafana.com/docs/k6
  • Stream results to Grafana/Prometheus/InfluxDB for live dashboards.
  • Practice: reproduce the JMeter TestForge scenario in k6, set p95<500ms and error<1% thresholds, and compare ergonomics and results between the two tools.