skip to content

In k6, what happens when a predicate inside a check() set throws an exception?

level: seniorimportance: should knowfreq 44%

answer

  1. two failure paths, not one
  2. the entry is recorded, then it rethrows
  3. later entries in the set never run
  4. a bare expression throws before check() is entered

basics

~20 s

A throwing predicate in a k6 check() set is recorded as a failed check under its own name, then the exception escapes check(): the remaining entries in that set never run, and the iteration ends there.

solid answer

~40 s

In k6 v2 a predicate that throws is treated as two events at once. First k6 substitutes `false` for its result and records that entry as a **failed check**, so the name still appears in the results. Then it stops: the captured error is re-thrown out of the `check()` call, so the remaining keys in the same set are never evaluated and the rest of that VU iteration does not run. This is very different from a predicate that merely returns something falsy — that one records a failure and lets every other entry proceed normally. The distinction matters because a `TypeError` inside a predicate is easy to write: `(r) => r.json('token').length > 0` throws the moment the login response has no `token` field.

code

javascript · 13 lines
javascript
import { check } from 'k6';

export default function () {
  const res = { status: 200, body: null }; // login response with no body

  check(res, {
    'login status is 200': (r) => r.status === 200, // recorded: pass
    'login body has a token': (r) => r.body.token !== undefined, // throws
    'never evaluated': () => true, // no record for this name at all
  });

  console.log('unreachable'); // the exception escaped check()
}

go deeper

for a junior

Know that a k6 check predicate is ordinary JavaScript, so it can throw like any other function, and that (r) => r.status === 200 is safe while reading into a nested field is not.

for a middle

Explain the split: a falsy result records a failure and lets the set continue, while a thrown error records the failure and then escapes the call, ending the iteration.

for a senior

Recognise the operational symptom — a check name that quietly stops appearing — and trace it to a sibling predicate that started throwing on an unexpected response shape.

for a principal

The tradeoff to argue is how defensive predicates should be across a suite: total predicates never abort an iteration but can mask a genuinely broken response contract.

## Two different kinds of failure k6's `check()` has two failure paths that look the same in a summary and behave nothing alike. | what the predicate does | that entry | the rest of the set | the iteration | |---|---|---|---| | returns a falsy value | recorded as failed | all still evaluated | continues normally | | **throws an exception** | recorded as failed | **never evaluated** | **ends there** | The first is the ordinary case everyone knows. The second is the one that turns a check into a script bug. ## What k6 does, step by step, when a predicate throws 1. It calls the predicate with the value under test. 2. The predicate throws. k6 catches the error and **substitutes `false`** as that entry's result. 3. It records the entry as a **failed check under its own name**, exactly as if the predicate had returned `false`. The failure is not lost. 4. It then **stops walking the set** and re-throws the captured error out of `check()`. Step 4 is the surprise. `check()` is described everywhere as the non-throwing alternative to an assert, and for a falsy result that is exactly true. A throwing predicate is a different animal: the exception is a real JavaScript exception propagating out of the call, and unless you wrapped the call in `try`/`catch` it terminates the current iteration. Concretely, a set of three entries whose second one throws produces **one** recorded failure — the second — and no record at all for the first-after-it or the third. The first entry, evaluated before the throw, is recorded normally. ## Why the remaining entries vanish The set is walked key by key, and the re-throw happens as soon as the failing entry has been recorded. There is no attempt to continue and collect the rest. The practical consequence is that a throwing predicate **hides its own neighbours**: the checks you wrote to explain a failure are precisely the ones that never run. That produces a confusing signal in a long run. A check name that used to appear on every iteration silently disappears from the results the moment an earlier sibling starts throwing — it did not fail, it was never asked. ## The bare-expression variant moves the throw outside the call k6 accepts a non-function value in a set, so both of these are legal: ```javascript check(res, { 'has token': (r) => r.json('token').length > 0 }); // function check(res, { 'has token': res.json('token').length > 0 }); // bare expression ``` They are not equivalent when the expression throws. The bare form is evaluated by JavaScript **while the object literal is being constructed**, before `check()` is ever entered. So when `token` is missing: - the **function** form throws inside `check()`, and the check is recorded as failed before the error escapes; - the **bare expression** form throws before `check()` is called, and the check is never recorded at all — the name simply does not exist in that iteration's results. Prefer the function form for anything that touches a nested field. It costs nothing and it guarantees that a broken response still leaves a fingerprint under the name you chose. ## Writing predicates that cannot throw The reliable defence is to keep predicates total — defined for every input, including the response you did not expect. - **Guard before you dereference.** `(r) => r.json('token') !== null` instead of `(r) => r.json('token').length > 0`. - **Compare, do not index.** `(r) => r.status === 200` cannot throw; `(r) => r.body.match(/id=(\d+)/)[1] === '7'` throws on any non-match. - **Check shape first, content second, in that order.** Because the set is walked in key order and a throw stops the walk, the entry that proves the body exists should come before the entry that reads into it. - **Use `try`/`catch` around the call only deliberately.** Catching turns the iteration-ending throw back into a local decision, but it also hides a genuine script defect, so log what you caught. - **Never let a predicate mutate anything.** A predicate that throws halfway has already done part of its work, and you have no way to know how much. The rule of thumb: a check should assert something about the system under test, never about whether your own script parsed the response correctly. If a predicate can throw, that second concern has leaked in.

  • Does a throwing predicate in a k6 check() still record a failed check?
    Yes. k6 substitutes `false` for the predicate's result and records that entry as a failure under its own name before re-throwing. So the failure is visible; what is lost is every entry after it in the same set, which is never evaluated.
  • How would you stop a throwing k6 check predicate from ending the iteration?
    Make the predicate total — guard before dereferencing, compare rather than index — so it returns `false` instead of throwing. Wrapping the `check()` call in `try`/`catch` also works, but it masks a real script defect, so log the caught error if you do.
  • Why can a k6 check name disappear from a run's results without ever failing?
    Because an earlier entry in the same set started throwing. The re-throw stops the walk, so later keys are never evaluated and produce no result — the check was not failed, it was never asked. A missing name is therefore as interesting as a failing one.

saying these in an interview costs you the question

  • Says check() never throws under any circumstances
  • Assumes every entry in the set is always evaluated
  • Thinks a throwing predicate leaves no record at all
  • Treats a bare expression and a predicate as identical
  • Dereferences a nested response field without a guard