# Port a @playwright/test spec

> For the complete documentation index, see [llms.txt](/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](/guides/from-playwright/).

## 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](/guides/collections/), 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`:

```ts
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:

```js
// 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](/concepts/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:

```js
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:

```json
{"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](/concepts/scenarios/#tags-and-soft-steps)
and the [DSL reference](/reference/dsl/).
