skip to content

In k6, what happens if a condition inside check() throws instead of returning false?

level: seniorimportance: nice to knowfreq 26%

answer

  1. the one interrupting path
  2. recorded first, rethrown after
  3. later keys never evaluated
  4. TypeError from a missing property
  5. guard with optional chaining

basics

~10 s

k6 records that entry as a failure - a zero-valued sample on the checks metric - and then rethrows. The remaining keys in the object never run, and the exception ends the iteration.

solid answer

~40 s

The usual rule — a failing `check()` never interrupts anything — has exactly one exception in k6 v2: an error raised *by* the condition itself. k6 invokes each condition, so when one throws, k6 substitutes `false` for that entry, pushes the zero-valued sample on the `checks` metric so the failure is still recorded and still tagged with its name, and then returns the error rather than continuing the loop. That surfaces at the `check()` call site as a thrown exception, so the remaining keys are never evaluated and the iteration ends the way any uncaught error ends it: logged, counted as a full iteration, run continues. The usual trigger is a condition reaching into a response shape that is missing, such as `(r) => r.json().order.id === 'c-42'` on an empty `500`.

code

javascript · 15 lines
javascript
import http from 'k6/http';
import { check } from 'k6';

export default function () {
  const order = http.post('https://example.com/checkout', '{"cart":"c-42"}');

  check(order, {
    // On a 500 with no body, r.json().order is undefined -> TypeError.
    'order id is c-42': (r) => r.json().order.id === 'c-42',
    // Never evaluated when the condition above throws.
    'checkout returned 201': (r) => r.status === 201,
  });

  http.get('https://example.com/receipt'); // unreachable that iteration
}

go deeper

for a junior

Know that the safe habit is writing conditions that always return a boolean. A condition that reaches into a response shape which may be missing can raise an error rather than simply failing.

for a middle

Explain the sequence: k6 substitutes false, emits the zero-valued sample so the failure is recorded, then propagates the error, which skips the remaining keys in the same object and ends the iteration.

for a senior

Bring the operational consequence: under load the response shape changes, checks_total falls below the number of declared conditions, and the log fills with TypeErrors. Show the defensive rewrite you would apply across a suite.

for a principal

Consider whether a team should forbid deep traversal inside check conditions altogether, extracting and validating the payload first, so that measurement never depends on the shape the system returns when it is failing.

## The one exception to "checks never interrupt" The headline rule for k6 v2 is that a failing `check()` changes no control flow: it records a zero-valued sample on the built-in `checks` metric, returns `false`, and the iteration carries on. That rule has exactly one exception, and it is not a failing condition — it is a condition that **raises an error while being evaluated**. The two cases look identical in the script and behave completely differently at runtime: - `(r) => r.status === 201` on a `500` response returns `false`. Recorded, no interruption. - `(r) => r.json().order.id === 'c-42'` on a `500` with an empty body evaluates `r.json()` to something with no `order` property, dereferences `undefined`, and **throws a TypeError**. Recorded *and* propagated. ## What k6 does with the thrown error k6 invokes each condition itself rather than letting your code call it, so it sees the error directly. For that key it then: 1. Substitutes `false` as the condition's value, so the outcome is treated as a failure. 2. Pushes the sample on the `checks` metric with value `0`, tagged `check: <that key's name>` — the failure is recorded exactly as an ordinary false result would be. 3. Returns the error instead of moving to the next key. That third step is what makes this different. The remaining keys in the same object are **never evaluated** and emit **no samples**. The error surfaces at the `check()` call site as a thrown JavaScript exception, and from there it behaves like any uncaught error in an iteration: k6's executor logs it at error level, counts the iteration as a full iteration, and starts the next one. So `check()` did, in this one path, end the iteration — but through the predicate's exception, not through the failure. | Condition outcome | Sample emitted | Later keys evaluated | Iteration ends | | --- | --- | --- | --- | | returns `true` | `1` | yes | no | | returns `false` | `0` | yes | no | | **throws** | `0` | **no** | **yes** | ## Why the checkout case hits it so often The endpoints that fail under load are precisely the ones that return bodies your conditions did not expect. A checkout service that answers `201` with `{"order":{"id":"c-42"}}` in staging may answer `500` with an empty body, an HTML error page, or `{"error":"..."}` when it is saturated. Every condition that reaches into the response shape is now a landmine: - Dereferencing a nested property that is missing. - Calling a string method on a field that came back `null`. - Parsing a body as JSON when the proxy returned HTML. The result is a run whose `checks_total` is lower than the number of conditions you wrote, because each affected iteration stopped part-way through its check object, plus a wall of error lines in the log. ## Writing conditions that cannot throw The fix is to make each condition **total** — able to produce a boolean for any input: 1. Guard the traversal with optional chaining: `(r) => r.json()?.order?.id === 'c-42'`. 2. Check the coarse thing first and the fine thing second, in separate `check()` calls, so a shape assumption is only made after the status has been confirmed. 3. Compute the value before the call and compare a plain variable inside the condition, so any parsing error happens in your own code where you can handle it. 4. Prefer comparisons that coerce safely (`===` against a value you extracted defensively) over ones that traverse deeply inline. A condition that can only return a boolean can only ever produce a recorded failure — which is the behaviour everyone assumes `check()` always has. ## What a strong answer includes Say that the failure is still recorded before the error propagates, that the later keys in the same object are lost, that the iteration ends the way any uncaught error ends it, and that the run's exit status is still untouched — the thrown condition does not fail the test any more than a false one does. Then name the defensive rewrite. Candidates who claim `check()` can never throw have read the summary of the behaviour rather than exercised it against a service that was actually broken.

  • Is the failing check still visible in the k6 summary when its condition threw?
    Yes. k6 pushes the zero-valued sample on the `checks` metric before it propagates the error, so the condition appears as a failure in `checks_failed` and under its own `check` tag. Only the keys after it in the same object are missing.
  • How do you keep a fragile k6 condition from ending the iteration?
    Make it total rather than partial: guard the traversal with optional chaining — `(r) => r.json()?.order?.id === 'c-42'` — or compute the value before the `check()` call and compare a plain variable. A condition that can only return a boolean can only produce a recorded failure.
  • Does the condition that threw still count toward checks_total in k6?
    Yes — its sample is pushed before the error propagates, so it lands in the total and in `checks_failed`. The keys that never ran contribute nothing, which is why `checks_total` for such a run is lower than the number of conditions the script actually declares.

saying these in an interview costs you the question

  • Says check() can never throw under any circumstance
  • Thinks a throwing condition is recorded as a pass
  • Expects the later keys in the object to still run
  • Assumes the exception is swallowed and only logged
  • Believes a throwing condition fails the whole k6 run