Module 6: Checks Groups And Realistic Scenarios
2. `check()` for correctness under load
Overview
In module 2 you met check() in its basic form: verify that a response meets certain named criteria and count how many pass. Now you put it to do the job it's really for in a realistic load test: verifying correctness. The distinction is deeper than it looks. A system can be responding —status 200, low latency— and yet be getting it wrong: returning a badly computed price, a body missing a key, a changed data type. Under load this happens more than one thinks, because the stress uncovers race conditions, corrupt caches, default values that sneak in. A test that only looks at the status will give you a reassuring green while the API delivers fast garbage.
Verifying correctness means checking three things about each response, not one. The first is the status: did the API respond without an error? The second is the value: is the price_cents exactly what it should be, computed with the same business rule? The third is the shape of the body: does the response carry the keys it promised, with the correct types —price_cents present and being an integer, not a null, not a string—? The three together answer the question that really matters: not "did it respond?", but "did it respond well?". In this lesson you write those three checks and run them for real against the canonical API, with varied data, to see the checks rate come out at 100% when the API is healthy.
Connection to the module: this lesson is the first of the four pieces of the realistic scenario. k6's check() is shown as content; the three criteria —status, value, shape— we actually run in the Python generator against the canonical API, counting real checks over quotes with varied data. Here we verify a single step (/quote); chaining several steps with checks at each one is the correlation of lesson 6. Everything labeled as Python output was measured in this environment with Python 3.14.0. The usual rule: the price is compared as an integer in cents, with the same formula as the API (rate × hours, integer pro discount).
The inspector who checks three things, not one
Go back to the factory's quality inspector you met in module 2, but now notice what they check. A bad inspector looks only at whether the part came out of the machine: "is there a part here? Yes. Approved." With that criterion, a deformed part, of the wrong material, or missing a hole would pass anyway, because it exists. A good inspector goes through a list: does it have the correct measurement? the correct material? the holes in place? Only if all three things add up is the part good. The difference between the two inspectors isn't how many parts they check, but how many criteria they apply to each.
In a load test, verifying only the status is being the bad inspector: "did the API respond? Yes (200). Approved." But a 200 response with the wrong price is a deformed part that passed the control. The good inspector applies three criteria to each response: status (did it come out without an error?), value (is the price exactly correct?), and shape (does the body have the keys and types it promised?). Under load, when the system starts to fail in strange ways, the value criterion and the shape criterion are the ones that catch what the status lets through. A check() with several criteria is that complete inspector.
Verifying correctness under load is applying three criteria to each response, not one: status (did it respond without an error?), value (is the
price_centsexactly correct?), and shape (does the body carry the expected keys and types?). Verifying only the status is approving deformed parts because they exist. The value check and the shape check catch what the status lets through.
The three checks in k6 (content)
We pick up the quote, now with the three correctness criteria. Each key of the object is the name of the criterion; each value, a function that receives the response (r) and returns true or false:
// quote_correctness.js - verify the CORRECTNESS of a quote.
// 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(res, {
// (1) status: the API responded without error
'status is 200': (r) => r.status === 200,
// (2) value: the price is EXACTLY correct (7500 for Focus/basic/3h)
'price is correct': (r) => r.json('price_cents') === 7500,
// (3) shape: the body carries price_cents and it's an integer (not null, not string)
'body shape is valid': (r) =>
typeof r.json('price_cents') === 'number' &&
Number.isInteger(r.json('price_cents')),
});
}
Take it apart criterion by criterion:
'status is 200'. The most basic criterion: did the API respond without an error? Necessary, but far from sufficient. A 200 says "the API worked," not "the API worked well."'price is correct'. The value criterion. It readsprice_centsfrom the body (withr.json) and checks it's exactly7500—the anchor number, compared as an integer, because money goes inintcents—. This check catches the bug the status doesn't see: an API that responds 200 but with the price badly computed.'body shape is valid'. The shape criterion. It doesn't look at what price it is, but that the body has the structure promised: thatprice_centsis a number and also an integer. It catches a different class of bug —an API that returns{"price_cents": null}, or{"price_cents": "7500"}(string), or that forgot the key— that neither the status nor a badly written value comparison would always detect.
The three together cover three different ways of failing: not responding (status), responding with the wrong number (value), and responding with a broken structure (shape). Each catches what the others let through.
The same three checks, run in Python
In the Python generator, each VU makes the same three verifications after each quote. And to make the test realistic from the start, the data is varied: each iteration picks a different row (several rooms, tiers, and hours), not always Focus/basic/3h. The expected price is computed with the same rule as the API:
# The three correctness criteria, run by each VU.
ROOM_RATES = {"Focus": 2500, "Studio": 4000, "Boardroom": 8000}
def expected_price(room, tier, hours):
total = ROOM_RATES[room] * hours
return total * 80 // 100 if tier == "pro" else total # integer pro discount
# ... after receiving status and body from POST /quote:
ok_status = status == 200 # (1) status
ok_price = body.get("price_cents") == expected_price(room, tier, hours) # (2) value
ok_shape = "price_cents" in body and isinstance(body["price_cents"], int) # (3) shape
# each True adds to its ok counter; each False, to its fail counter
Notice the shape criterion in Python: "price_cents" in body and isinstance(body["price_cents"], int) checks the key exists and that its value is an integer. It's the exact equivalent of k6's typeof ... === 'number' && Number.isInteger(...). The three criteria are the same on both faces.
Let's run the generator with 4 VUs for 3 seconds (with a small 50 ms think time, for readable numbers). Each iteration does three checks, so we expect 3 × iterations checks in total, all passing if the API is healthy.
What to expect. With the correct API, the three criteria must always pass for any data: 100% of checks. Real output in this environment:
vus............: 4
iterations.....: 209
checks.........: 100.00% (627 of 627)
status is 200...............: 209 ok / 0 fail
price is correct............: 209 ok / 0 fail
body has price_cents:int....: 209 ok / 0 fail
Read it:
iterations: 209— 4 VUs hittinglocalhostwith a 50 ms think time completed 209 quotes in 3 seconds. Each with possibly different data (Focus, Studio, or Boardroom; basic or pro; varied hours).checks: 100.00% (627 of 627)— each iteration does 3 checks (status, value, shape), so 209 iterations give 627 checks. All 627 passed: for each of the varied data, the API responded 200, with the price exactly correct and with the expected shape.- The per-criterion breakdown —
status is 200: 209 ok / 0 fail,price is correct: 209 ok / 0 fail,body has price_cents:int: 209 ok / 0 fail— is exactly what the k6 summary would give you with the names you put. All three green: the API is correct, not just alive.
That the price added up for all rooms and tiers —not just for Focus/basic— is the proof that the value check is well done: it uses the same formula as the API (rate × hours, integer pro discount) for any data, not a fixed magic number. That's what makes it useful when you parametrize (lesson 5).
Why the status alone isn't enough (the bug hiding behind a 200)
Imagine the API, under stress, starts returning the price without applying the pro discount —a classic bug: the discount branch is skipped by a race condition—. The response would still be a perfect 200, with a well-formed body ({"price_cents": 7500} for Focus/pro/3h, when it should be 6000). What check would catch it?
- The status (
status is 200): green. The API responded without an error. It notices nothing. - The shape (
body has price_cents:int): green. The body carriesprice_centsand it's an integer. It also notices nothing —7500is a perfectly valid integer, just the wrong number—. - The value (
price is correct): red.7500 != expected_price("Focus", "pro", 3), which is6000. Only this criterion catches the bug.
This is the moral of the lesson, and you'll see it measured in lesson 3: there are bugs that only the value check detects. A system under load can respond fast, with status 200 and a valid shape, and still be computing wrong. If your test only looks at the status —or even status + shape—, that bug passes invisible. The value check, which compares against what the response should have been, is the one that really protects correctness.
(And note: the three criteria complement each other, they don't replace each other. The shape one catches null/string/missing-key that the value one sometimes couldn't even compare; the value one catches the wrong number that the shape one approves. That's why all three go.)
Common mistakes
Verifying only the status and calling it "correctness." What happens: someone adds only 'status is 200' and thinks their test verifies the API works well. Why it happens: a 200 feels like "all correct." How to detect it: if the API returned a wrong price with status 200, your test would stay 100% green and you wouldn't notice. How to fix it: add the value check (correct price_cents) and the shape check (the key exists and is of the expected type). The status is the first of three criteria, not the only one.
Comparing the price against a fixed magic number. What happens: someone writes r.json('price_cents') === 7500 and leaves it like that even when the data varies. Why it happens: it works while you only quote Focus/basic/3h. How to detect it: as soon as you parametrize (quote Studio, or pro, or more hours), the check fails for everything that isn't Focus/basic/3h, because 7500 is no longer the correct price. How to fix it: compute the expected value with the same rule as the API (expected_price(room, tier, hours)) for that iteration's data. A fixed number only works if the data is fixed.
Forgetting the shape check and breaking with a null. What happens: someone verifies only status and value, and when the API returns {"price_cents": null} under stress, the value comparison (null == 6000) gives False —which is fine—, but in other languages or with other accesses the null could blow up the check code instead of counting it as a failure. Why it happens: it's assumed the body always carries the key with the correct type. How to detect it: strange errors or checks that aren't counted when the body comes malformed. How to fix it: add the shape check that explicitly verifies the key exists and is of the expected type, before reasoning about its value.
Exercises
Exercise 1 — Write the three checks for /book. Write (in k6, as content) a check for a POST /book response that verifies correctness with three criteria: status 200, that confirmed is exactly true (boolean value), and that the body has a booking_id that's a non-empty string (shape). Give descriptive names.
See solution
check(res, {
'status is 200': (r) => r.status === 200,
'booking is confirmed': (r) => r.json('confirmed') === true,
'booking_id shape is valid': (r) =>
typeof r.json('booking_id') === 'string' && r.json('booking_id').length > 0,
});
status is 200is the status criterion.confirmed === trueis the value criterion: not just that the key exists, but that it's exactlytrue(not"true", not1).- The third criterion is the shape:
booking_idpresent, of type string and non-empty. (You could refine it by checking it starts with'bk-'.)
Exercise 2 — Which criterion catches each bug? For each failure the Reservo API could have under load, say which of the three criteria (status, value, shape) would catch it. (a) The API returns 500 instead of the price. (b) The API returns 200 with {"price_cents": 7500} for a Focus/pro/3h quote (it should be 6000). (c) The API returns 200 with {"price_cents": null}. (d) The API returns 200 with {"cost": 6000} (the key is named wrong).
See solution
- (a) The status criterion (
status is 200fails, because it's 500). - (b) The value criterion (
price is correctfails:7500 != 6000). The status is 200 and the shape is valid; only the value catches it. - (c) The shape criterion (
price_centsisn't an integer, it'snull). The value one would also giveFalse(null != ...), but the shape one is what diagnoses it cleanly. - (d) The shape criterion (
price_centsisn't in the body: the key iscost). The value one couldn't even readprice_cents.
The three criteria cover three different families of failure; that's why they go together.
Exercise 3 — Read the count. In the real run, the summary said checks: 100.00% (627 of 627) with 209 iterations. (a) Why 627 and not 209? (b) If the value criterion had failed in 40 of the 209 quotes (and the other two stayed green), what total percentage of checks would the summary show? (c) What would you have missed if you had only verified the status?
See solution
- (a) Because there are 3 checks per iteration (status, value, shape): 209 × 3 = 627 total checks. The "of 627" in the summary is the total of verifications, not of iterations.
- (b) The status and shape would stay green (209 + 209 = 418 ok), and the value would have 169 ok / 40 fail. Total: 418 + 169 = 587 ok of 627 = 93.62%. The failure in a single criterion lowers the total, and the breakdown would tell you exactly which (the value one).
- (c) You would have missed any value or shape bug: if the API responded 200 with the wrong price or a malformed body, the status check would stay 100% green and you'd declare the API healthy. Only the value and shape checks reveal it responded badly.
Summary and next step
In this lesson check() went from "did the API respond?" to "did it respond well?". Verifying correctness under load is applying three criteria to each response: status (without an error?), value (is the price_cents exactly correct, computed with the business rule?), and shape (does the body carry the promised keys and types?). You ran it for real in Python with varied data: 4 VUs / 3 s gave 627 of 627 checks (100%), with the price adding up for all rooms and tiers because the expected value is computed with the same formula as the API. And the underlying lesson is clear: there are bugs —a badly computed price, a malformed body— that a status 200 hides, and that only the value check or the shape check catch.
Before moving on you should be able to: write a check with the three correctness criteria and descriptive names; compute the expected price with the same rule as the API to verify the value with varied data; explain why verifying only the status is insufficient; and say which family of bug each criterion catches.
Lesson 3 returns to a promise from module 2: a failing check does not stop the run. Now you see it with consequences —the check measures but doesn't decide—, and you contrast it with the threshold (module 5), which does give the pass/fail verdict with an exit code. You'll run the same quote with a buggy price check (the iteration completes anyway, but the checks rate drops) and put a threshold gate on top that makes it fail for real.
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/http—r.statusandr.json(), what the three check criteria inspect. How the status, value, and shape of the response are read. isinstance— Python built-in functions — the function the shape check verifiesprice_centsis an integer with. How to verify a value's type in Python.- Google SRE Book — Service Level Objectives — the frame for why "correct" is a dimension of quality as measurable as "fast." The conceptual background of verifying correctness, not just latency.