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

Port a @playwright/test spec

For the complete documentation index, see llms.txt

A @playwright/test spec file often holds a describe with a beforeEach and several test(...) calls. A unotest scenario file holds the same shape: one test_* function per former test, with what beforeEach did in a function they all call. For call-by-call equivalents, see From @playwright/test.

What a test of a file gets

Each test_* of a file is a test of its own:

  • its own run, browser context, retry and verdict, with its own failure bundle, screenshots and console;
  • the file’s functions, shared; the file’s state, not — a value one test sets is not there in the next;
  • a row of its own under the file in the viewer, a count of its own in a collection, and a name of its own in a box’s notifications (file › test).

e2e <file> plays the tests in the order they are written. e2e <file> --test <name> plays one.

A spec with four tests

The spec, as written for @playwright/test:

test.describe('users table', () => {
test.beforeEach(async ({ page }) => {
await signIn(page);
});
test('prev is disabled on the first page', async ({ page }) => {
await page.goto('/dashboard/users');
await expect(page.getByRole('button', { name: 'Prev' })).toBeDisabled();
});
test('next moves to the second page', async ({ page }) => {
await page.goto('/dashboard/users');
await page.getByRole('button', { name: 'Next' }).click();
await expect(page.getByText('Page 2 of')).toBeVisible();
});
test('sorting by name reorders the rows', async ({ page }) => { /* … */ });
test('new user opens the form', async ({ page }) => {
await page.goto('/dashboard/users');
await page.getByRole('button', { name: 'New user' }).click();
await expect(page.getByLabel('Email')).toBeVisible();
});
});

The scenario:

// beforeEach lives in a helper, unotest/e2e/_helpers/users.js:
// function flow_open_users() { flow_signin(); goto('/dashboard/users'); }
// Each test runs in a browser context of its own, so each one signs in.
function test_prev_is_disabled_on_the_first_page() {
step("Prev is disabled on page 1", () => {
flow_open_users();
assertTrue(isDisabled(getByRole('button', {name: 'Prev'})), 'Prev should be disabled on page 1');
});
}
function test_next_moves_to_the_second_page() {
step("Next moves to page 2", () => {
flow_open_users();
click(getByRole('button', {name: 'Next'}));
assertText(getByText('Page 2 of', {exact: false}), 'Page 2 of', {exact: false});
});
}
function test_sorting_by_name_reorders_the_rows() {
step("Sorting by name reorders the rows", () => {
flow_open_users();
const before = textContent(nth(getByRole('row'), 1));
click(getByRole('button', {name: 'name', exact: true}));
assertTrue(textContent(nth(getByRole('row'), 1)) != before, 'the first row should change');
});
}
function test_new_user_opens_the_form() {
step("New user opens the form", () => {
flow_open_users();
click(getByRole('button', {name: 'New user'}));
assertVisible(getByLabel('Email'));
});
}

What maps to what:

  • test(...) → test_*. Each former test keeps its own run and verdict. One that fails does not stop the others.
  • beforeEach → a flow_* helper. Every test calls it first. It is shared code, not shared state: each test starts in a fresh browser context.
  • describe → the file. The file’s name groups the tests; their names say what each checks.
  • Helpers live in unotest/e2e/_helpers/, so other files can use the same journey.

When the checks are steps of one journey rather than separate tests — one sign-in, then several checks that report on their own — keep one test_* and make each check a step.soft(...): a failed soft step is recorded and the run goes on, and the test ends failed with every failed step listed.

One test over many cases

Playwright runs the same test over a list with a loop around test(...). In a scenario the loop is inside the test, and each case is a soft step tagged with its id. The cases can live in the scenario or in a JSONL file:

function test_pages_from_a_table_and_a_file() {
step("Cases from a table in the scenario", () => {
const cases = [
{id: 'hub', path: '/', text: 'Stress-test browser'},
{id: 'clicks', path: '/scenarios/clicks', text: 'Click me'},
{id: 'drag-drop', path: '/scenarios/drag-drop', text: 'Backlog'},
];
for (const c of cases) {
step.soft("Page opens", {tag: c.id}, () => {
goto(c.path);
waitForText(c.text);
});
}
});
step("Cases from a JSONL file", () => {
for (const id of ['hub', 'clicks', 'drag-drop']) {
const row = readJsonLine('unotest/fixtures/data/playground-pages.jsonl', {id, enabled: true});
step.soft("Page opens", {tag: row.id}, () => {
goto(row.path);
waitForText(row.text);
});
}
});
}

playground-pages.jsonl holds one case per line:

{"id":"hub","path":"/","text":"Stress-test browser","enabled":true}
{"enabled":true,"text":"Click me","path":"/scenarios/clicks","id":"clicks"}
{"id":"drag-drop","enabled":true,"path":"/scenarios/drag-drop","text":"Backlog"}
  • {tag: c.id} names the case. The CLI prints it next to the step label (Page opens [clicks]), and the viewer shows it as a chip on the step, so a failed case is found by its id, not by its position.
  • readJsonLine(path, filter) returns the first line whose keys match the filter, from a file that already exists.
  • The outer step is a group, not an assertion: when one case fails it still closes, and the viewer shows “1 of 3 soft failed” on it. The test ends failed with that case listed.

More on tags and soft steps: Scenarios and the DSL reference.