Writing a k6 load test and analyzing results in CI
A k6 load test is a JS file exporting a default function that calls http.get()
or http.post(), asserts behavior with check(),
and paces itself with sleep() — with thresholds
like http_req_duration: ['p(95)<200'] acting as its pass/fail bar instead of a single
boolean assertion. Export the run’s summary as JSON, upload it with the Qualflare CLI, and judge threshold breaches against history —
a single run’s p95 latency means almost nothing without a baseline. Here’s the walkthrough, honest about what k6’s “failures” actually are.
- 1
Write the script: requests, checks, and pacing
A k6 test is a JS file exporting a default function. Call http.get() or http.post() to make requests, wrap the response in check() to assert behavior (e.g. status was 200), and add sleep() between iterations so virtual users pace like real traffic instead of a request cannon.
- 2
Set thresholds — k6’s version of a pass/fail assertion
A unit test asserts one boolean outcome. A k6 threshold is a statistical bar applied across every request in the run, e.g. thresholds: { http_req_duration: ['p(95)<200'] }. k6 only exits non-zero when a threshold breaches — a single failed check() does not fail the build on its own unless you also put a threshold on the checks metric.
- 3
Model real traffic with scenarios (optional, beyond one flat load)
A single vus/duration or stages block is enough for a quick check. For ramp-ups, steady soak tests, or spikes, define scenarios with an executor: ramping-vus for a gradual ramp, constant-vus or constant-arrival-rate for steady load. k6 has no dedicated "spike" executor — you simulate one with a ramping executor whose stages jump sharply and drop again.
- 4
Export the run summary, not the raw stream
k6 run --out json=results.json streams every single data point — useful for external dashboards, but more than a CI step needs. For CI, export the aggregated end-of-test numbers instead with k6 run --summary-export=k6-summary.json (k6 now also offers the more flexible handleSummary() function for custom output; --summary-export remains supported and is the simplest one-flag option).
- 5
Upload the summary to Qualflare after every run
Add qf <project> collect k6-summary.json --format k6 as a CI step, and run it with if: always() so it uploads even when a threshold fails — that failure is exactly the signal worth tracking. The CLI attaches your Git branch and commit, turning each load test run into a tracked launch alongside your functional test results.
- 6
Judge threshold breaches against history, not one run
A single run’s p95 latency is nearly meaningless without a baseline — it’s one noisy sample, not a fact. Let a handful of runs accumulate, then check whether a breach is a one-off blip (shared-runner contention, network jitter) or the start of a genuine upward trend before you call it a regression.
The script: requests, checks, and thresholds
Below is a complete, runnable script: a login (POST) followed by the endpoint under test (GET), each asserted
with check(), with thresholds
set on request duration, error rate, and the check pass rate itself:
// load-test.js
import http from 'k6/http';
import { check, sleep } from 'k6';
export const options = {
stages: [
{ duration: '30s', target: 20 }, // ramp up to 20 VUs
{ duration: '1m30s', target: 20 }, // hold steady
{ duration: '20s', target: 0 }, // ramp down
],
thresholds: {
http_req_duration: ['p(95)<200'], // 95% of requests must finish under 200ms
http_req_failed: ['rate<0.01'], // fewer than 1% of requests may fail
checks: ['rate>0.99'], // 99%+ of check() assertions must pass
},
};
export default function () {
// POST — simulate a login
const loginRes = http.post(
'https://api.example.com/login',
JSON.stringify({ email: 'user@example.com', password: 'secret' }),
{ headers: { 'Content-Type': 'application/json' } }
);
check(loginRes, { 'login succeeded': (r) => r.status == 200 });
// GET — the endpoint under test
const res = http.get('https://api.example.com/dashboard');
check(res, { 'status was 200': (r) => r.status == 200 });
sleep(1);
}
Note the difference from a unit test: nothing above fails the run by itself. A failed check()
is tracked in the built-in checks rate metric, but k6 only exits non-zero when a
threshold breaches — that’s intentional, so one slow request in a thousand
doesn’t sink the build the way one failed assertion sinks a Playwright or Jest run.
Scenarios: modeling more than one flat load
stages is enough for a single ramp up/steady/ramp down shape. For anything more
deliberate — a slow ramp plus a separate steady soak, or a sudden spike — use scenarios,
each with its own executor. ramping-vus ramps virtual users through stages;
constant-vus and constant-arrival-rate hold
steady load. k6 has no dedicated “spike” executor — you simulate one with a ramping executor whose stages jump sharply and drop again:
// Same test, modeled as independent load patterns instead of one flat ramp
export const options = {
scenarios: {
ramp_up: {
executor: 'ramping-vus',
startVUs: 0,
stages: [
{ duration: '2m', target: 50 },
{ duration: '5m', target: 50 },
{ duration: '2m', target: 0 },
],
},
steady_soak: {
executor: 'constant-vus',
vus: 30,
duration: '10m',
},
},
}; Getting k6 results into Qualflare
k6 can export two very different things. Don’t reach for the verbose one in CI:
# Every data point, one line each — verbose, built for external dashboards/debugging
k6 run --out json=results.json load-test.js
# Aggregated end-of-test numbers instead — percentiles, rates, and each
# threshold's pass/fail result. This is the shape Qualflare's k6 parser reads.
k6 run --summary-export=k6-summary.json load-test.js
Upload that summary file with the Qualflare CLI. The --format k6 flag
selects the k6-aware parser, which reads the percentiles, rates, and threshold pass/fail results — the same
qf <project> collect <file> pattern used for every other framework:
qf my-project collect k6-summary.json --format k6
In GitHub Actions, that’s the official setup-k6-action and
run-k6-action plus one upload step. Authenticate the Qualflare CLI once with an
access token stored as a CI secret — see the CLI docs:
# .github/workflows/load-test.yml
- uses: grafana/setup-k6-action@v1
- name: Run k6 load test
uses: grafana/run-k6-action@v1
with:
path: load-test.js
flags: --summary-export=k6-summary.json
- name: Upload results to Qualflare
if: always() # upload even when a threshold fails — that's the signal
run: qf my-project collect k6-summary.json --format k6 Is a threshold breach “flaky”? Being honest about it
Most of this site talks about flaky tests — a test that flips pass/fail on unchanged code because of non-determinism in the code path itself: timing, shared state, ordering. A k6 threshold breach is not that. It’s a statistical statement over many requests, so when it fails intermittently with no code changes, the far more common cause is environmental variance — a shared CI runner under resource contention, network jitter, a noisy neighbor VM — not a bug in your test or your service.
That said, it is still a form of flakiness in the practical sense: an intermittent signal you can’t fully trust from one run alone. The honest framing is “a flaky threshold caused by infrastructure noise,” not “a flaky test” in the traditional sense — same instinct (don’t judge one run), different mechanism (a distribution shifting under load, not a boolean flipping under a race condition).
This is also why historical trend data matters even more here than for pass/fail tests. A functional test’s result is close to binary, so a rerun on the same commit is usually enough evidence. A load test’s p95 latency is a number sampled from a noisy process — one run’s pass or fail tells you almost nothing about whether performance is actually degrading. What tells you is the trend: p95 creeping upward over the last 20 builds is a regression; p95 bouncing within its usual band, with an occasional breach on a busy runner, is noise. Uploading every k6 summary to Qualflare after every run is what builds that trend line — the same reason flaky-test detection needs history instead of a single result, applied here to a continuous metric instead of a pass/fail one.
k6 tests load and performance, not application logic — for frameworks that assert business behavior the traditional way, see all framework reporting guides, including Playwright, pytest, Cypress, Jest, and JUnit. New to the terms? See flaky test detection and test observability in the glossary.
Frequently asked questions
Is a k6 threshold failure the same thing as a flaky test?
Not quite. A flaky unit test flips a single pass/fail assertion on unchanged code because of non-determinism in the code path. A k6 threshold is a statistical bar applied over hundreds or thousands of requests, so it can breach because of environmental variance — a noisy CI runner, network jitter, a cold cache — rather than a code regression. Call it a flaky threshold caused by infrastructure noise, not a flaky test in the traditional sense. Either way, the fix is the same instinct: don’t trust one run.
Does a single failed check() fail my CI build?
No, by design. k6 tracks check() results in the built-in checks rate metric, but a failed check does not by itself abort the test or set a failing exit code. Your build only fails when a threshold breaches — either directly on a metric like http_req_duration, or indirectly by putting a threshold on the checks metric itself, e.g. checks: ['rate>0.99']. If you want individual assertion failures to fail CI, you have to wire them to a threshold.
What’s the difference between --out json= and --summary-export?
--out json=results.json streams every single data point, one line per request or sample — useful for external dashboards or deep debugging, but far more than a CI step needs. --summary-export writes the aggregated end-of-test numbers instead: percentiles, rates, and whether each threshold passed or failed, which is the shape Qualflare’s k6 parser reads. k6’s own docs now point toward the more flexible handleSummary() function for custom output, but --summary-export is still supported and is the simplest one-flag option for this.
Do I need a separate reporting setup for k6, unlike Playwright or Jest?
No. The upload step — qf <project> collect <file> --format k6 — is identical to every other framework Qualflare supports; only the file you point it at changes. k6 sits alongside your functional test results as just another tracked launch.
Why does historical trend data matter more for load tests than for unit tests?
A unit test’s result is close to binary — pass or fail on this commit. A load test’s p95 latency is a number sampled from a noisy process, so one run tells you almost nothing about whether performance is actually getting worse. You need the run-over-run trend — is p95 creeping up over the last 20 builds, or just bouncing within its usual range — to tell a real regression from noise. It’s the same reason flaky-test detection needs multiple runs instead of one, applied here to a continuous metric instead of a boolean.
Track k6 load test results over time
Upload your k6 summaries after every CI run and let Qualflare build the trend line — free to start.
Start free with Qualflarek6 script structure, thresholds, scenarios, and CLI flags verified against k6’s official documentation as of July 2026. Qualflare CLI usage reflects the docs.qualflare.com reference.