Skip to content

Early Adopter Offer:Get 40% off Core & Scale for your first year with code EARLYQFView pricing

k6 test reporting

k6 prints an excellent end-of-test summary and then throws it away. Next week’s run prints another one, and nothing anywhere knows your p(95) has moved 40% since spring. Qualflare turns those runs into hosted, historical reporting: checks as pass rates, thresholds as their own cases, and a trend line across every run instead of a number in a terminal.

First: your failed checks are not failing your build

This surprises nearly everyone who puts k6 into CI, and it is documented behaviour rather than a bug. Grafana’s own docs state that “failed checks do not cause the test to abort or finish with a failed status” and that “when a check fails, the script will continue executing successfully and will not return a ‘failed’ exit status”. In practice:

$ k6 run script.js          # no thresholds defined
  ✓ status is 200 ......... 58234 / 61000   (95.5%)
  ✗ responds under 500ms .. 41200 / 61000   (67.5%)

$ echo $?
0     # a third of requests were too slow. The run passed.

A third of requests breached your latency expectation and the pipeline went green. The fix is in the same docs — “if you need the whole test to fail based on the results of a check, you have to combine checks with thresholds” — which is why a CI-bound k6 script should almost always declare a checks threshold alongside its metric thresholds:

import http from 'k6/http';
import { check, group } from 'k6';

export const options = {
  vus: 50, duration: '2m',
  thresholds: {
    http_req_duration: ['p(95)<500'],
    // Make the checks actually fail the run:
    checks: ['rate>0.99'],
  },
};

export default function () {
  group('checkout', () => {
    const res = http.post('https://api.example.com/checkout');
    check(res, {
      'status is 200': (r) => r.status === 200,
      'responds under 500ms': (r) => r.timings.duration < 500,
    });
  });
}

Why the summary isn’t reporting

Even with thresholds wired up correctly, a threshold is a pass/fail line and nothing more. A p(95) of 480ms passes p(95)<500 — and so did last month’s 310ms. The threshold tells you the moment you crossed the line; it cannot tell you that you have been walking steadily toward it for six weeks, which is the part you could still have done something about.

That is the specific gap for load testing. Functional suites need history to separate flakes from regressions; performance suites need it because the trend is the result. A single run is a measurement, not an answer.

Send k6 results to Qualflare

Export the end-of-test summary as JSON. Nothing in your script changes:

# Write the end-of-test summary as JSON
k6 run --summary-export=k6-summary.json script.js

Then upload it:

qf my-project collect k6-summary.json --format k6

In CI that’s one changed line and one added step. Authenticate the CLI once with your Qualflare access token, stored as a CI secret — see the CLI docs.

# .github/workflows/load-tests.yml
- name: Run load test
  run: k6 run --summary-export=k6-summary.json script.js

- name: Upload results to Qualflare
  if: always() # upload even when thresholds fail — that's the point
  run: qf my-project collect k6-summary.json --format k6

if: always() matters here specifically because a breached threshold makes k6 exit non-zero — so without it, the runs that failed your performance budget are exactly the runs that never get recorded. One version note: k6 v2.0.0 reworked summary modes, removing --no-summary and the legacy mode, so on v2 confirm your exported file still contains the root_group and metrics objects before wiring up a gate.

What you get on top of the summary

  • Checks as pass rates, not booleans. A check that failed 19,800 of 61,000 times is reported as failing at 67.5%, not collapsed to a red tick.
  • Thresholds as their own cases. Tagged and named after the metric they guard, so the aggregate assertions stay distinct from the per-response ones.
  • Trends across runs. p(95), error rate and check pass rates plotted over time — the thing a per-run summary structurally cannot give you.
  • Group-qualified names. Two checks called “status is 200” in different groups stay two checks instead of silently merging into one.
  • AI failure analysis. Failing checks and breached thresholds from one run summarised into causes and a risk level, alongside your functional suites.
  • One place for every suite. Load results sit next to your Playwright and Newman runs rather than in a separate tool.

k6 summary vs Qualflare

  k6 summary Qualflare
Trend across runs (p95, error rate)Yes
Failing checks recorded even on a green runYes
Load results beside functional suitesYes
AI analysis & release riskYes
Real-time metric streaming during a runYes
Local, zero-setup, offlineYes

Complementary, and deliberately so: k6 and its live dashboards remain the right tools for watching a run happen. Qualflare is for what the run means compared to the last thirty.

Track your load tests across runs

Start free — add --summary-export, run qf collect, and get your first trend in minutes.

Get Started Free

Qualflare works the same with Newman, Playwright, pytest, Go, RSpec and 20+ more frameworks. Prefer reading first? See tracking k6 results across runs, not just thresholds, or the step-by-step guide to writing a k6 load test and analyzing it in CI. Weighing tools? See how it compares to other test management platforms, or browse all framework reporting guides. Every reporter is open source.

How a load test becomes a set of test cases

A functional suite has an obvious mapping: one test, one result. A load test does not. With 50 virtual users over two minutes, a single check() executes tens of thousands of times, and k6’s summary records it as aggregate passes and fails counts rather than as individual outcomes. So the honest unit is a check, reported as a pass rate:

checkout > status is 200            passed
checkout > responds under 500ms     failed   67.5% pass rate (41200 passed, 19800 failed)
Threshold: http_req_duration p(95)<500   failed
Threshold: checks rate>0.99              failed

Zero failures is a pass; zero passes is a failure; anything in between is reported as failed with its rate attached, because a check that fails a third of the time is not a passing check and rounding it to green would throw away the only interesting number in it. Thresholds are kept separate — each becomes its own case, tagged and named after the metric and expression it guards — since checks and thresholds answer genuinely different questions. A check asserts something about individual responses; a threshold asserts something about an aggregate across the whole run, and only the threshold moves the exit code.

One detail worth knowing if your script uses group(): check names are qualified with their group path, so you see checkout > status is 200 rather than a bare status is 200. That is not cosmetic. Cases are deduplicated by name within a suite, and “status is 200” is the most reused check name in existence — two groups asserting it would otherwise collapse into one case and quietly drop a result, turning a partial failure into a green report. Grouping your script well is what makes those names readable.

Finally, the reason to keep every run rather than only the failures. Performance regressions almost never arrive as a single dramatic breach; they arrive as a series of individually acceptable runs. Recording the passing runs is what makes the slope visible, and the slope is what lets you act before the threshold — and your users — notice. That is also why uploading on always() matters: a history made only of failures has no baseline to measure against.

Frequently asked questions

How do I send k6 results to Qualflare?

Export the end-of-test summary as JSON with k6 run --summary-export=k6-summary.json script.js, then upload it with qf my-project collect k6-summary.json --format k6. No change to your script is required. Note that k6 v2.0.0 reworked summary modes — it removed --no-summary and the legacy summary mode — so if you are on v2, confirm your exported file still contains the root_group and metrics objects the parser reads.

Do failed k6 checks fail the test run?

No, and this is the single most important thing to know about k6 in CI. Grafana’s documentation is explicit: "failed checks do not cause the test to abort or finish with a failed status" and "when a check fails, the script will continue executing successfully and will not return a failed exit status". A load test where a third of requests were too slow can still exit 0. To make checks fail a run you must combine them with thresholds — the docs say so directly.

How do checks appear in the report — pass or fail?

As a pass rate, because that is what a k6 check actually is. With 50 virtual users over two minutes, one check runs tens of thousands of times, and the summary records aggregate passes and fails rather than a per-iteration outcome. A check with zero failures is reported as passed; one with zero passes as failed; anything in between is reported as failed with its rate — for example "67.5% pass rate (41200 passed, 19800 failed)" — because a check that fails a third of the time is not a passing check.

Are thresholds reported separately from checks?

Yes. Every threshold becomes its own case, tagged threshold and named after the metric and expression it guards — for example "Threshold: http_req_duration p(95)<500". That keeps the two concepts distinct in the report, which matters because they answer different questions: a check asserts something about individual responses, while a threshold asserts something about an aggregate metric across the whole run, and only the threshold controls exit status.

What happens if two checks in different groups share a name?

They stay separate, because check names are qualified with their group path — "checkout > status is 200" rather than a bare "status is 200". This is deliberate: cases are deduplicated by name within a suite, so two identically named checks in different groups would otherwise silently merge into one and drop a result, which is a false green. Using group() in your script is what makes those names readable.

Why track load test results over time instead of per run?

Because performance regressions are gradual and a single run has nothing to compare against. A p(95) of 480ms passes a threshold of 500ms — and so did last month’s 310ms. The threshold tells you the moment you crossed the line; run history tells you that you have been walking toward it for six weeks, which is the part you can still act on.

Setup reflects the Qualflare CLI (docs.qualflare.com) as of September 2026. The check and threshold exit-status behaviour is quoted from Grafana's k6 documentation; the pass-rate reporting and group-qualified naming are from the CLI's k6 parser, which reads root_group and metrics[].thresholds directly. Written by İbrahim Süren, Qualflare.