How do k6's exec.test.abort() and exec.test.fail() differ in effect and in exit code?
answer
- one interrupts, one only marks
- immediate stop versus complete run
- both still reach teardown
- 108 interrupt, 110 status flag
basics
~10 sexec.test.abort() stops a k6 run immediately and exits 108; exec.test.fail() marks the run failed, lets every iteration finish, and exits 110. Both take an optional message and both still run teardown().
solid answer
~40 s`exec.test.abort()` raises an interrupt: the current iteration unwinds, the scheduler brings the whole run down, and k6 exits **108**. `exec.test.fail()` raises nothing — it logs its reason at error level, sets a boolean on the run status, and execution continues; after the run ends k6 sees that flag and exits **110**. Both take an optional message, both live on the `test` object of `k6/execution`, and `teardown()` runs in both cases. The consequence that matters is data: after 108 the run is a fragment cut short at an arbitrary moment, while after 110 it is a complete run whose verdict is simply already decided.
code
javascript · 15 linesimport exec from 'k6/execution';
export const options = { vus: 1, iterations: 3 };
export default function () {
if (exec.scenario.iterationInTest === 0) {
// Logs, flips the run status, and returns. Iterations 1 and 2
// still run; the process exits 110 after the run completes.
exec.test.fail('unexpected response body');
}
}
export function teardown() {
console.log('teardown runs either way');
}go deeper
Pair the names with the numbers: abort stops the run and exits 108, fail lets the run finish and exits 110. Both accept an optional message string.
Explain the mechanism, not just the numbers: abort raises an interrupt that unwinds the iteration, while fail only sets a boolean the command checks after the scheduler returns.
Argue the data consequence — 108 leaves a truncated run whose statistics describe less load than configured, while 110 leaves a complete, comparable run with the verdict already decided.
The question worth owning is when a script should decide the verdict at all rather than leaving it to measured rules, since a hand-coded failure is a rule nobody can see in the options object.
## Two ways for a k6 script to declare its own failure Thresholds let k6 decide the verdict from measurements. The `k6/execution` module gives a script two ways to decide it directly, and they behave very differently: - **`exec.test.abort([msg])`** — stops the entire test run **immediately** and exits **108**. - **`exec.test.fail([msg])`** — marks the run failed but **lets it finish**, then exits **110**. Both take an optional string message. Both are reached through the `test` object on the default export of `k6/execution`, and k6 documents them qualified that way — `exec.test.abort()`, never a bare `test.abort()` with the object left unnamed. ## abort(): an interrupt `abort()` raises an interrupt inside the JavaScript runtime. In Go terms it constructs an `errext.InterruptError`, whose `ExitCode()` method returns `exitcodes.ScriptAborted` — 108 — and whose abort reason is `AbortedByScriptAbort`. The interrupt unwinds the current iteration, and the scheduler brings the whole run down; no further iterations start in any VU. Two details are worth memorising: 1. **`teardown()` still runs.** k6's own test fixture aborts from the default function and asserts that the teardown log line appears. Aborting is not a hard kill. 2. **The message is prefixed.** `exec.test.abort('oops!')` surfaces as `test aborted: oops!`, with the source position appended, in both the log and the run's exit event. An abort from the script's **init context** — module top level, before any VU runs — also exits 108, but the run never reaches the end-of-test stage at all. ## fail(): a flag, not an interrupt `fail()` does not throw and does not interrupt anything. It does exactly three things: it composes the reason `test marked as failed` (plus your message, if given), logs it at error level, and calls `MarkFailed()` on the run's status. Then your iteration continues from the next statement, and every other iteration in the run proceeds normally. The exit code arrives much later. After the scheduler returns, `internal/cmd/run.go` checks `testRunState.TestStatus.Failed()` and, if it is set, returns an error carrying `exitcodes.MarkedAsFailed` — 110. Calling `fail()` a hundred times is the same as calling it once; the status is a boolean, not a counter. One constraint: `fail()` needs a VU. Called where there is no VU state — the init context — it throws `execution.test.fail() called outside of VU context` instead of marking anything. ## Side by side | | `exec.test.abort()` | `exec.test.fail()` | |---|---|---| | exit code | 108 | 110 | | stops the current iteration | yes, immediately | no | | stops the rest of the run | yes | no — all iterations finish | | `teardown()` runs | yes | yes | | effect of calling it twice | the first one already ended the run | identical; the status is a flag | | legal in the init context | yes | no — it throws instead | | remaining measurements | lost, the run is cut short | complete, the run ran in full | ## Choosing between them The distinction that matters for a build step reading the code is **whether the rest of the run's data is trustworthy**. After 108, the run is a fragment: it stopped at an arbitrary moment, so its totals and its per-metric statistics describe less load than you configured. After 110, the run is complete — the same duration, the same iterations, the same summary you would have got had nothing failed — with one bit flipped to say "this result is a failure". A job that wants to keep the artefacts and compare them against a previous run can do that after 110 and cannot honestly do it after 108. That makes the practical rule easy to state: abort when continuing would be pointless or actively harmful — the environment is wrong, a dependency is unreachable, every request is failing — and fail when the run should complete so its numbers stay comparable, but its verdict is already decided. There is a reporting difference too. `fail()` logs its reason at error level on **every** call, so the log records each occurrence even though the status is a single boolean and the exit code is set once. `abort()` records exactly one reason, prefixed with `test aborted:` and carrying the source position, because there is only ever one abort per run. ## In k6 v2 Both functions are on the stable `k6/execution` module in k6 v2.x, and both exit codes are fixed constants in `errext/exitcodes/codes.go`. Neither has anything to do with thresholds: they set the code directly, without evaluating any metric.
- Does calling exec.test.fail() several times in a k6 run change anything?No. `fail()` sets a boolean on the run status, so the hundredth call is the same as the first. Each call does log its reason at error level, so the log shows every occurrence, but the run still exits 110 exactly once.
- Where can exec.test.abort() and exec.test.fail() legally be called in a k6 script?`abort()` works anywhere, including the init context at module top level — k6 has fixtures for aborting during initial parsing and during VU initialisation, and all exit 108. `fail()` requires VU state; called from the init context it throws `execution.test.fail() called outside of VU context` rather than marking the run.
- After exec.test.abort() ends a k6 run early, are the collected metrics still usable?Only as a fragment. The run stopped at an arbitrary moment, so iteration counts and per-metric statistics describe less load than was configured and cannot honestly be compared with a full run. That is the main argument for `fail()` where the run should complete.
One is the emergency stop on a production line — everything halts where it stands, though the shutdown checklist is still worked through. The other is stamping the day's batch "rejected" while the line keeps running to the end of the shift.
saying these in an interview costs you the question
- Thinks exec.test.fail() stops the run immediately
- Says exec.test.abort() skips teardown()
- Claims both produce the same exit code
- Calls exec.test.fail() from the init context
- Expects repeated fail() calls to change the exit code
- Assumes either function evaluates thresholds first