This documentation is available as Markdown. For the complete index, see llms.txt. Skip to content

Scenarios & step()

For the complete documentation index, see llms.txt

A scenario is a plain .js file in unotest/e2e/. It holds one or more test_* functions — each one a test of its own — and every executable step lives inside a step() block.

function test_checkout() {
step("Add the first product to the cart", () => {
goto("/products");
click(getByRole("button", { name: "Add to cart" }));
});
step("Cart shows one item", () => {
assertText(getByTestId("cart-count"), "1");
});
}

Two layers

Each step() carries two layers at once:

  • Intent — the string label, plain English. Reads like a checklist, even to a non-engineer.
  • Execution — the DSL calls inside the closure. One step can be several calls.

This is why repair is precise: the agent knows what a step is meant to do (its label) and how it does it (the calls), so it rewrites only the broken part — it doesn’t guess. The same duality helps you: collapse a step to see the logic, expand it to see the exact commands.

Tags and soft steps

A data-driven test runs one test_* over many cases. Two additions to step() keep such a run readable:

function test_control_questions() {
step("Control questions", () => {
for (let i = 1; i <= 10; i++) {
const q = readJsonLine('questions.jsonl', {slot: i});
step.soft("Question", {tag: q.id}, () => {
fill(getByRole('textbox', {name: 'Message'}), q.text);
assertJudge(getByTestId('last-answer'), q.rubric);
});
}
});
}
  • {tag: <expr>} — the case this iteration is on. Any expression; it shows up as Question [q17] in the CLI and as a chip on the step in the viewer.
  • step.soft(...) — a failure inside is recorded and the run goes on with the next case; the test still ends failed, listing every soft failure. Allowed inside test_* only, at any depth. The outer step is a group, not an assertion: it closes fine and shows “1 of 10 soft failed”.

A worked example with the cases in a table and in a JSONL file: Port a @playwright/test spec.

App constants in scenarios

A scenario cannot import your app’s modules. When a test needs values the app keeps in source (button labels, a device list, a limit), don’t copy them into the scenario. Export them to JSON with a small script and read the file with readJson:

// scripts/export-test-constants.ts: writes what the scenarios read
import { mkdirSync, writeFileSync } from 'node:fs';
import { CHAT_LABELS } from '../src/shared/chat-labels.ts';
import { DEVICES } from '../src/shared/devices.ts';
mkdirSync('unotest/generated', { recursive: true });
writeFileSync(
'unotest/generated/shared.json',
JSON.stringify({ labels: CHAT_LABELS, devices: DEVICES }, null, 2),
);

Name the constants themselves. import * as labels would put the whole module in the file: every export under its own name (labels.CHAT_LABELS.send), and a zod schema exported next to them as a dump of its internals.

step("Send uses the app's own label", () => {
const shared = readJson('unotest/generated/shared.json');
click(getByRole('button', {name: shared.labels.send}));
});

Since 0.49.0, name the script in the config’s generate:

unotest/unotest.config.mjs
export default {
generate: 'node --experimental-strip-types scripts/export-test-constants.ts',
};

It runs from the project root, the same base as a collection’s prepare, before every local e2e and collection, before bundle push, and in box create before the box is rented. So an edit of the source reaches the next run without a manual step, and the bundle carries a snapshot of the source you push. A box has your suite but not your application, so it never runs the command: it reads the snapshot that came in the bundle. init keeps unotest/generated/ out of git, and bundle push packs it anyway. When the command fails, the run is refused with exit 95 before anything starts; --no-generate skips it for one run.

On earlier versions, run the script yourself before a run and before bundle push. Don’t use the collection’s prepare for it: prepare runs on the box too, and the box has no application source to read.

The file is generated: don’t edit it by hand. The path is relative to the project and cannot leave it.

The DSL in one breath

Navigation (goto, waitForUrl), locators by stability (getByTestId → getByRole → getByLabel → getByText → locator), actions (click, fill, press, selectOption…), assertions (assertText, assertVisible…), chaining (getByRole(...).filter(...).first()), multi-tab and iframes, and sandbox helpers (dbQuery, apiCall, shell). See the DSL reference.

The language is JavaScript

Inside a step, a helper or a flow, a scenario is plain JavaScript: let / const, arrows and closures, loops of every kind, switch, try / catch, destructuring, template strings, ?. and ??. Calls to the page return their result: the runner does the waiting, so a scenario has nothing to await. A loop over cases goes inside a step, as in the example above.

Since 0.52.0, scenarios run on V8, the engine of Node and Chrome, with the semantics of the language as it is: classes, getters, generators, Date, Symbol, instanceof and the built-in objects all work. What a scenario adds is the vocabulary — click, getByRole, apiCall, … — and what it does not have is the APIs of Node and of the browser:

  • require, process, fetch — use apiCall(…), shell(…) or an external UPPER_SNAKE variable.
  • window, document, localStorage — evaluate(fn, arg) runs a function in the page.
  • setTimeout, setInterval — pause(ms) or waitUntil(() => …).
  • console.log prints nowhere — log(…) or note(label, value).
  • import / export — helpers in _helpers/ are visible by name.

Don’t write async or await: an async function returns at its first await, and what follows runs after the test is over. Declare every variable (let i = 0, not i = 0); lint --fix writes the missing let. Moving a suite from an earlier version: Upgrade to scenarios on V8.

Before 0.52.0 the language is a white list — Math, JSON, Object, Map, Set and the string, number and array methods — and what JavaScript has beyond it (class, Date, timers, host globals) is a lint error that names what to write instead. See DSL → Not supported.

Expected failures

A test that is supposed to fail — the failure is the assertion — carries // @expect-fail in the // comments directly above its function line, with no blank line between:

// id-checkout-flow
// Checkout survives a declined card
// #a13f7c
// @expect-fail
function test_checkout() { /* … */ }

In a file of several tests the marker marks that test only. A marker in the file’s leading comments, set apart from the first function by a blank line, marks every test of the file. An // @expect-fail anywhere else is a comment about a step. The runner reads the marker: a marked test that fails is reported as failed on purpose (a collection whose only failures are on purpose exits 0; e2e of such a test still exits 1), and a marked test that passes fails with a message saying the failure it expects is gone. A marked test that breaks on a script error (an unknown function, an unset variable) is a plain error — not the failure it promised. The viewer shows a failure on purpose as expected (its own colour, not in the failed count) and a green run of a marked test as failed — the thing it guarded stopped being broken without anyone noticing.