Module 2: The K6 Script And Virtual Users

4. `check()`: verifying the response

Overview

A load test that only measures speed can lie to you. Imagine the Reservo API, under the pressure of hundreds of users, starts responding very fast... because it returns an empty 500 error instead of computing the price. The latencies would look excellent —never faster!—, and yet the application is broken. The lesson is uncomfortable: fast isn't the same as correct. That's why, alongside the timing metrics, k6 gives you a tool to verify that each response was valid: the check() function.

A check is a named verification: you give it the response and a set of conditions —"the status is 200," "the price is 7500"— and k6 evaluates each one and counts how many passed and how many failed. It resembles an assert from unit tests, but with a crucial difference that defines its role in a load test: a failing check doesn't abort the test. The run continues, the other VUs keep hitting, and at the end the summary tells you "98% of the checks passed." That tolerance is deliberate: under load you want to measure how many responses came out wrong, not stop at the first one. In this lesson you learn to write checks, to read their count, and to see them catch a real bug.

Connection to the module: lesson 3 taught you to read the response (res.status, res.json('price_cents')); this one turns that reading into a formal verification. k6's check() is shown as content; the same criteria —status 200 and correct price— we actually run in the Python generator against the canonical API, counting real checks. And so you see that a check is useful, we'll run a version with a bug in the expected price and watch it fail in a measurable way. Everything labeled as Python output was measured in this environment with Python 3.14.0. Here check() is seen in its basic form; its in-depth use —grouping with group(), parametrizing data, correlating quote→book— is module 6.

The quality inspector at the end of the line

Think of it this way. In a factory that produces thousands of parts per hour, at the end of the belt there's a quality inspector. They don't stop the line every time they see a defective part —that would halt the entire production and you wouldn't know how many fail—. Instead, they check each part against a list of criteria ("is the measurement correct?", "is the color the expected one?"), mark the good and the bad, and at the end of the shift report: "of 10,000 parts, 9,800 passed, 200 failed the measurement criterion." That report is gold: it tells you what fails and how much, without having stopped the factory.

check() is that inspector. For each response that comes back, it evaluates a list of named criteria —"status is 200," "price is 7500"— and keeps count of how many times each one passed. It doesn't abort the run when something fails (that would be stopping the line); it keeps measuring, and at the end the summary gives you the report: "checks 98.04%, 800 of 816." Compared with an assert from a unit test —which does stop everything at the first failure, like an inspector who shuts down the factory— the check is made for volume: you want the percentage of bad responses under load, not a screeching halt.

check() verifies that a response meets certain named criteria and counts how many pass and how many fail, but does NOT abort the test when something fails —unlike an assert—. It's a quality inspector reporting the percentage of bad parts without stopping the line. Under load you want to measure how many responses come out wrong, not stop at the first one.

check() in k6 (content)

We pick up the quoting script, now with the verification. The check receives the response and an object where each key is the name of the criterion and each value is a function that receives the response (r) and returns true or false:

// quote_check.js — quote and verify the response.
// SHOWN AS CONTENT: k6 is not installed in this environment.
import http from 'k6/http';
import { check } from 'k6';

const BASE_URL = 'http://localhost:8000';

export default function () {
  const payload = JSON.stringify({ room: 'Focus', tier: 'basic', hours: 3 });
  const params = { headers: { 'Content-Type': 'application/json' } };
  const res = http.post(`${BASE_URL}/quote`, payload, params);

  // check(response, { 'criterion name': (r) => boolean condition })
  check(res, {
    'status is 200': (r) => r.status === 200,
    'price is 7500': (r) => r.json('price_cents') === 7500,
  });
}

Take it apart:

  • check(res, { ... }). The first argument is the response to inspect; the second, an object with one or more criteria. Here there are two.
  • 'status is 200': (r) => r.status === 200. The key 'status is 200' is the name you'll see in the summary —choose it descriptive, because it's what you'll read when something fails—. The value is a function that receives the response r and returns true if the status is 200. This is the most basic criterion: did the API respond well?
  • 'price is 7500': (r) => r.json('price_cents') === 7500. The second criterion goes beyond the status: it checks the content. It reads price_cents from the body (with res.json, as in lesson 3) and verifies it's exactly 7500 —the anchor number, compared as an integer, because money goes in int cents—. This check catches a bug the status wouldn't see: an API that responds 200 but with the wrong price.

Notice the pair: verifying status 200 says "the API worked"; verifying the price says "the API worked well." Under load, both matter: a stressed system can keep responding 200 but start computing wrong, and only the second check detects it.

The same check, run in Python

In the Python generator, each VU makes the same two verifications after each quote: it compares the status with 200 and the received price_cents against the expected price (which we compute with the same rule as the API: rate × hours, with integer pro discount). Each verification that passes adds to checks_passed; each one that fails, to checks_failed:

# The equivalent of k6's check(): two verifications per response.
def expected_price(room, tier, hours):
    rate = {"Focus": 2500, "Studio": 4000, "Boardroom": 8000}[room]
    total = rate * hours
    if tier == "pro":
        total = total * 80 // 100     # integer pro discount (int cents)
    return total

# ... inside each VU's script, after receiving the response:
ok_status = status == 200
ok_price  = body.get("price_cents") == expected_price(room, tier, hours)
# counted: each True adds to checks_passed, each False to checks_failed

Let's run the generator with 3 VUs for 3 seconds (with a one-second think time, for small, readable numbers). Each iteration does two checks, so we expect 2 × iterations checks in total, all passing if the API is healthy.

What to expect. With the correct API, both criteria must always pass: 100% of checks. Real output in this environment:

----------------------------------------------------------
  Python load generator  ->  3 VUs / 3s / think 1000ms
----------------------------------------------------------
  vus............: 3
  real duration..: 3.04s
  iterations.....: 9   (3.0/s)
  checks.........: 100.00%   (18 of 18)
    status is 200....: 9  ok / 0  fail
    price is correct.: 9  ok / 0  fail
  http_errors....: 0
  req_duration...: avg=3.20ms  min=0.86ms  max=6.95ms
----------------------------------------------------------

Read it:

  • iterations: 9 — 3 VUs × ~3 laps each (one per second because of the sleep(1)) = 9 iterations.
  • checks: 100.00% (18 of 18) — each iteration does 2 checks (status and price), so 9 iterations give 18 checks. All 18 passed: the API responded 200 and the price always added up.
  • status is 200: 9 ok / 0 fail and price is correct: 9 ok / 0 fail — the breakdown per criterion. This breakdown is exactly what the k6 summary gives you with the names you put in the check. The API is healthy: green on both.

A 100% of checks is reassuring, but also suspicious —does the check really do something, or does it always pass?—. To prove a check is useful, you have to see it fail.

Seeing the check catch a bug

Let's provoke a meaningful failure. Imagine the code that verifies the price has a bug: it forgets the pro discount. That is, for a pro quote it expects the basic price (without the 20% discount), which doesn't match what the API —correctly— returns. A well-written check should catch exactly that discrepancy:

# BUGGY VARIANT: the expected price forgets the pro discount.
def buggy_expected(room, tier, hours):
    rate = {"Focus": 2500, "Studio": 4000, "Boardroom": 8000}[room]
    return rate * hours          # BUG: ignores the 'pro' tier (doesn't apply *80//100)

With this faulty expected value, the basic quotes will still add up (they carry no discount), but the pro ones will fail the price check: the API returns the discounted price and the expected (buggy) value carries the price without discount. Since the generator picks the tier at random between basic and pro, approximately half of the price checks should fail. Let's run 3 VUs / 3 s (with a 200 ms think time):

What to expect. The status check will stay at 100% (the API always responds 200), but the price check will fail on the pro quotes. Real output in this environment:

----------------------------------------------------------
  Generator with BUG in the price check -> 3 VUs / 3s / think 200ms
----------------------------------------------------------
  iterations.....: 45
  checks.........: 76.67%   (69 of 90)
    status is 200....: 45  ok / 0  fail
    price is correct.: 24  ok / 21  fail   <-- the check catches the pro prices
----------------------------------------------------------

This output teaches what a check is good for:

  • checks: 76.67% (69 of 90) — of 90 total checks (45 iterations × 2 criteria), 69 passed and 21 failed. The percentage dropped from 100% to 76.67%: the summary warns you something doesn't add up, with a number.
  • status is 200: 45 ok / 0 fail — the status stayed perfect. The API never stopped responding 200. If you had only verified the status, you wouldn't have noticed anything: all green, and yet there's a problem.
  • price is correct: 24 ok / 21 fail — here's the bug, caught. Of 45 quotes, ~24 were basic (they add up) and ~21 were pro (they fail, because the expected value forgot the discount). The content check —not the status one— was the one that detected the discrepancy. That's the moral: verifying only the status is insufficient; verifying the value of the response is what catches logic bugs under load.

(In this example the bug is in the test's expected value, to illustrate; in real life the bug would be in the API and the check would warn you just the same. In both cases, the check did its job: it flagged that the response wasn't the one it should be, and did so with a number, without aborting the run.)

check is not assert: why the run didn't stop

A detail you already saw in action and that's worth underscoring: in the buggy run, despite 21 failed checks, the test completed its 45 iterations. It didn't stop at the first failure. That's the essential difference from an assert in a unit test:

  • An assert (pytest, unittest) aborts as soon as a condition isn't met: the test is marked failed and stops. Perfect for correctness tests, where you want to know whether something is wrong.
  • A check (k6) records and continues: it counts the failure and continues the iteration and the run. Perfect for load tests, where you want to know how many responses came out wrong among thousands, under pressure.

And if you want the test to fail for real —return an error code, gate a deploy— when too many checks fail? That's what thresholds are for, the subject of module 5. A threshold can say "if more than 1% of the checks fail, the test fails." The check measures; the threshold decides. In this module we stay with the measuring: counting checks. Remembering this avoids the mistake of expecting a failed check to bring down the run —it doesn't, and that's on purpose—.

Common mistakes

Expecting a failed check to abort the test. What happens: someone adds a check, runs the test, sees a criterion fail, and is surprised the run kept going to the end "as if nothing happened." Why it happens: they bring the mental model of assert, which does stop everything. How to detect it: the summary shows failed checks (for example 76.67%) but the run completed all its iterations and exited successfully. How to fix it: understand that check measures, doesn't abort. If you need the test to fail on exceeding a failure threshold, that's a threshold (module 5), not a check.

Verifying only the status and not the content. What happens: someone adds only 'status is 200' and trusts that's enough. Why it happens: a 200 feels like "all good." How to detect it: as in the buggy run —status 100% green but wrong prices—, the test passes the status and doesn't notice the logic is broken. How to fix it: verify the value of the response too (price_cents === 7500). The status says the API responded; the content says it responded well. Under load, a stressed system can respond 200 and compute wrong.

Vague check names. What happens: someone names their criteria 'check1', 'ok', 'test'. Why it happens: when writing them it seems the name doesn't matter. How to detect it: when a check fails, the summary says ✗ check1 and you have no idea what broke. How to fix it: name each criterion by what it verifies —'status is 200', 'price is 7500', 'booking confirmed'—. The name is what you'll read in the report; making it descriptive is what makes the diagnosis useful.

Exercises

Exercise 1 — Write the checks. Write (in k6, as content) a check for a POST /book response that verifies three things: that the status is 200, that the response carries confirmed: true, and that the booking_id isn't empty. Give descriptive names.

See solution
check(res, {
  'status is 200': (r) => r.status === 200,
  'booking is confirmed': (r) => r.json('confirmed') === true,
  'booking id is present': (r) => r.json('booking_id') !== '',
});
  • Each key is a descriptive name that will appear in the summary.
  • r.json('confirmed') === true verifies the boolean content.
  • r.json('booking_id') !== '' checks the id doesn't come empty. (You could refine it, for example, by checking it starts with 'bk-'.)

Exercise 2 — Read the count. In the buggy run, the summary said checks: 76.67% (69 of 90), with status is 200: 45 ok / 0 fail and price is correct: 24 ok / 21 fail. (a) How many checks run per iteration and how many iterations were there? (b) Why did the status stay at 100% but the total checks dropped to 76.67%? (c) What would you have missed if you had only verified the status?

See solution
  • (a) 2 checks per iteration (status and price). There were 45 iterations: 45 × 2 = 90 total checks, which is the "of 90" in the summary.
  • (b) Because the failure was only in the price criterion, not the status one. The status passed all 45 times (45/45), but the price failed 21 times (24/45). Adding both criteria: 45 + 24 = 69 good checks of 90 = 76.67%.
  • (c) You would have missed the bug entirely: the status stayed 100% green. Only the content check (the price) revealed that ~half of the pro quotes carried the wrong value. Verifying only the status would have given you a false sense that everything was fine.

Exercise 3 — check vs assert. A colleague comes from writing unit tests with assert and says: "in my load test I added a check, but when the API returned a bad price, the test kept running instead of stopping. Is k6 broken?" Explain what's happening and how they'd get the test to fail if too many responses come out wrong.

See solution

k6 isn't broken: that's how check works on purpose. Unlike an assert —which aborts at the first failure— a check records the failure and continues, because in a load test you want to measure how many of thousands of responses came out wrong, not stop at the first one. That's why the run completed its iterations and the summary showed the percentage of checks that failed.

For the test to fail for real (for example, return an error code and gate a deploy) when too many responses come out wrong, it needs a threshold —the subject of module 5—: something like "if the failed-check rate exceeds 1%, the test fails." The check measures; the threshold decides the verdict.

Summary and next step

In this lesson the VU went from reading the response to verifying it. check(res, { ... }) evaluates named criteria —"status is 200," "price is 7500"— and counts how many pass and how many fail, without aborting the test (unlike an assert): it's a quality inspector reporting the percentage of bad parts without stopping the line. You ran it for real in Python: with the healthy API, 3 VUs / 3 s gave 18 of 18 checks (100%); with a bug that forgot the pro discount, the price check caught the discrepancy (24 ok / 21 fail, 76.67% total) while the status stayed green —the proof that verifying only the status isn't enough, and that a content check is what uncovers logic bugs under load—.

Before moving on you should be able to: write a check with several criteria and descriptive names; verify both the status and the value of the response; read the check count in the summary; and explain why a failed check doesn't stop the run and what (a threshold) would make it fail.

Lesson 5 returns to a line we've used without fully explaining: sleep(), the think time. You'll see why, without that pause, your test measures an unreal storm instead of realistic usage —and you'll confirm it with the same VU running with and without the pause, measuring how the think time governs the pace—.

Resources

  • Checks in k6 — the reference for check(): the syntax with named criteria and why a failed check doesn't abort the test. The exact source of this lesson.
  • The Response object — k6/httpres.status and res.json(), what the check's criteria inspect. How to read the content to verify.
  • Thresholds in k6 — how to turn the failed-check rate into a verdict that makes the test PASS or FAIL. The "after" of the check, in depth in module 5.
  • assert — Python documentation — the statement that does abort at the first failure, to contrast with the "measure without stopping" philosophy of the check.