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

From @playwright/test

For the complete documentation index, see llms.txt

This page maps @playwright/test code to unotest scenarios. Each section covers one topic and gives a table: the Playwright call on the left and the unotest equivalent on the right.

A Playwright test and a unotest scenario drive the same browser engine. The difference is shape: a scenario is JavaScript whose calls to the browser are awaited for you, and the browser never calls back into it. What Playwright does with a handler, unotest does with a fixed answer declared up front.

Tabs

In Playwright you catch a new tab with an event promise that you set up before the click. unotest needs no promise. waitForPage() also finds a tab that opened before the call, so the click comes first.

Playwrightunotest
const [tab] = await Promise.all([context.waitForEvent('page'), btn.click()])click(btn); waitForPage(); — switches to the tab, returns its index
context.waitForEvent('page', { timeout: 5000 })waitForPage({timeoutMs: 5000})
context.pages()[1] as the page the test drives, another tab in frontsetPage(1, {activate: false})
context.pages()[1].bringToFront()bringToFront(1) — the steps stay on their tab
await expect(tab).toHaveURL(/report/)assertUrl('/report') once the tab is active (substring match)
const tab = await context.newPage(); await tab.goto(url)newPage(url) — the tab becomes active; returns its index
await tab.close()closePage() — the steps return to the tab active before it; closePage(i) closes another tab

waitForPage() returns each tab once. A second call waits for the next tab. Tabs that were open at the start, or that setPage already switched to, don’t count as new. See Multi-tab & iframes.

Network mocking

Masks are the same URL globs Playwright takes, matched for every tab and frame of the scenario’s browser context.

Playwrightunotest
context.route(glob, r => r.fulfill({ json }))mockRoute(glob, { body: {...} })
r.fulfill({ status, body, headers, contentType })mockRoute(glob, { status, body, headers, contentType })
context.route(glob, r => r.abort())mockRoute(glob, { abort: true })
context.route(glob, r => r.continue())mockRoute(glob, { continue: true })
context.unroute(glob) / unrouteAll()unmockRoute(glob) / unmockRoute()
page.waitForRequest(glob) + request.postDataJSON()req = waitForRequest(glob), then req.postDataJson
page.waitForResponse(glob) + response.json()res = waitForResponse(glob, { status: 200 }), then res.json — since 0.55.0
page.on('response', …) + response.json() after a clicknetworkEntries({glob}) → [{url, status, responseJson, …}] — recorded from the page’s opening, so act first and ask afterwards
a handler that aborts and remembers what left the sandboxmockRoute('**/*', { abort: true, record: true }) + assertNoRequest('**/*')
  • Order. As in Playwright, the mask registered last answers first. Mocking a mask again replaces its answer and makes it the latest, so an allowlist is mockRoute('**/*', {abort: true}) followed by mockRoute('**/localhost:*/**', {continue: true}).
  • No handler logic. A response that depends on the request is out of reach — mock the variants you need one after another, or serve them from a fixture API.
  • Your page stays reachable. The page document of the baseUrl origin is never aborted, and a fixed response to **/* is refused: either would fail the test on a blank page. Only the document is spared. The page’s own scripts, styles and API calls need their own continue mask, as in the allowlist above; for a site on a domain it is mockRoute('https://app.example.com/**', {continue: true}).
  • Requests already sent. waitForRequest on a mocked mask finds a request sent before the call, so it can come after the click that sends it — no Promise.all dance.
  • Answers already in. waitForResponse (since 0.55.0) looks at requests sent from the start of the last call that acted on the page, so it comes after the click too, and an answer faster than the step is found. When another action stands between them, pass {since} from a nowMs() taken before the click.

Polling

expect.poll and toPass both become waitUntil. It runs the body again and again until the body is truthy, then returns that value. The body is an expression, or a block that gives its value with return.

Playwrightunotest
await expect.poll(() => count()).toBeGreaterThan(4)waitUntil(() => count() > 4)
expect.poll(fn, { timeout: 30000, intervals: [500] })waitUntil(() => ..., {timeoutMs: 30000, intervalMs: 500})
expect.poll(fn, { message: 'job done' })waitUntil(() => ..., {label: 'job done'})
await expect.poll(async () => (await request.get(url)).status()).toBe(200)waitUntil(() => apiCall('GET', url).status == 200)
await expect(async () => { ...; expect(x).toBe(y) }).toPass()waitUntil(() => { ...; return x == y; })
let n = 0; await expect.poll(() => ++n).toBeGreaterThan(2)let n = 0; waitUntil(() => { n = n + 1; return n > 2; }) — both lines inside the step
  • Only a falsy value is retried. toPass retries a thrown assertion; waitUntil fails the step at once when its body throws. Write the check as a condition that returns false instead of an assert.
  • Timeout. Without timeoutMs it waits as long as the other waits of the run (defaultTimeoutMs); intervalMs defaults to 250. On timeout the step fails naming the label, the number of tries and the last value.
  • State between tries. A counter or the last value seen lives in a let inside the test_ function or the step, and the body writes it. A let at the top level of the file never runs: the validator refuses it, and the body finds no such variable.

Web servers

webServer takes the same fields as Playwright’s — one object or a list.

Playwrightunotest
webServer: { command, url, reuseExistingServer, timeout }webServer: { command, url, reuseExistingServer, timeoutMs }
webServer: [{ command, url }, { command, url }]webServer: [{ command, url }, { command, url }] — started together, stopped in reverse order
webServer.porturl only: readiness is an HTTP answer at that URL

If one server of a list does not come up, the ones that did are stopped and the error names each that failed.

Schemas

A zod schema can’t run in a scenario, but the JSON Schema generated from it can: the app exports it next to its constants, the scenario reads it with readJson and checks a value with assertSchema. When the contract changes, the check follows it, with no field-by-field asserts to update by hand.

Playwrightunotest
MessageSchema.parse(body)assertSchema(body, readJson('unotest/fixtures/message.schema.json'))
z.toJSONSchema(MessageSchema)the same call in a build script, written to the file the scenario reads
a ZodError listing every issuethe step fails naming every broken field by path: /tags/1: must be string
  • Draft 2020-12. That is what z.toJSONSchema writes. A schema that declares another draft is refused rather than checked by the wrong rules.
  • One file. A $ref inside the schema (#/$defs/tag) is followed; one to another file is refused. Put shared parts under $defs.
  • format is not checked. email, uuid, date-time are annotations here; check such a field with assertTrue if it matters.

Skip

A test skips itself from its own body, on a value it reads with env() or on data it finds: the run reads as skipped, with the reason.

Playwrightunotest
test.skip(!process.env.LIVE_API, 'no live API')skip('no live API', env('LIVE_API') == null) at the top of the test_* body
test.skip(condition, 'reason') inside the test, on dataskip('reason', condition) anywhere in the test_* body, inside the step that reads the data too
process.env.NAMEenv('NAME') — null when unset; a secret is refused
  • Inside the test. skip() works at any depth of a test_* body: next to the steps, inside a step, in an if block. Nothing after it runs; the step it was called in shows as skipped, the steps before it stay passed.
  • Not in a helper or a flow_*. There it would skip every test that calls it, so the linter reports it as an error.
  • Teardown still plays. What the test set up before the skip is given back.

Timeouts

A test’s budget is a deadline, as in Playwright: when it runs out, the action in flight is cut, the test fails and its teardown blocks still play.

Playwrightunotest
timeout: 30_000 in playwright.config.tstestTimeoutMs: 30000 in unotest.config.mjs (default 300000)
test.setTimeout(90_000) inside a testsetTestTimeout(90000) at the top of the test_* body
test.slow()setTestTimeout(…) with the longer budget, spelled out
npx playwright test --timeout=60000UNOTEST_MAX_DURATION_MS=60000 for one run
  • Failure, not stop. A test that runs out of time is red, counted as failed, and the message names the budget it spent. Stop in the viewer aborts the run instead.
  • Only in the test. setTestTimeout() in a helper or a flow_* is a lint error.
  • Scenario code is cut between statements. A browser action or host call in flight is cut at the deadline. Plain computation in the scenario is not cut in the middle of one call: a regular expression that backtracks for seconds runs to its end, then the test fails on the budget.

Helpers & constants

A scenario can’t import: it runs on the scenario engine, the same one on your machine, on a box and under an agent. Shared code moves once, into the DSL. Shared values stay in your app and reach the scenario as generated JSON.

Playwrightunotest
e2e/helpers/*.ts (export async function signIn(page))unotest/e2e/_helpers/*.js with function flow_sign_in() { ... }, callable from every scenario
import { LABELS } from '../src/shared/labels'a script exports it to unotest/fixtures/shared.json, the scenario reads const shared = readJson('unotest/fixtures/shared.json')
LABELS.chat.sendshared.chat.send, same . and [] access
schema.parse(message) (zod)the app exports the schema as JSON Schema, the scenario checks with assertSchema — see Schemas
test.extend({ ... }) fixtureslaunch.userDataDir for one shared browser profile, launch.profile: 'per-test' for a new one per test, _helpers for setup, teardown(label, () => { ... }) for cleanup

Regenerate the JSON from the collection’s prepare, so every run reads the current source. The recipe is in Scenarios; helpers in Helpers.

Files

A scenario has no fs and no Buffer. It reads files through the DSL, and only files inside the project or ones the scenario downloaded: a path relative to the project root, an absolute one inside it, or the path that waitForDownload() returns. A path out of them, or a symlink that leads out, is refused.

Playwrightunotest
fs.readFileSync(path, 'utf8')readFile(path)
fs.readFileSync(path).toString('base64')readFile(path, {encoding: 'base64'})
fs.statSync(path).sizereadFileInfo(path).size
a PNG header parsed by hand, or sharp(path).metadata()readFileInfo(path).png: {width, height}, absent for a file that is not a PNG
JSON.parse(fs.readFileSync(path, 'utf8'))readJson(path)
fs.readFileSync(await download.path())readFile(waitForDownload().path)

readFileInfo reads only the PNG header, so a large image costs one small read.

Assertions & values

assertEqual compares deeply, by JSON value: key order does not matter, and a missing key is not the same as null. On a mismatch it names the first path that differs and shows both values.

Playwrightunotest
expect(body).toEqual({ theme: 'dark' })assertEqual(body, {theme: 'dark'})
expect(a).toBe(b)assertEqual(a, b)
JSON.parse(raw)JSON.parse(raw), or parseJson(raw)
new URL(src).searchParams.get('roomId')urlParam(src, 'roomId') — null when the key is absent
text.match(/id=(\d+)/)?.[1]the same: text.match(/id=(\d+)/)?.[1]
expect(text).toContain('ready')assertTrue(text.includes('ready'), text)
await expect(loc).toHaveText(/^#\d+$/)assertText(loc, /^#\d+$/)
await expect(loc).not.toHaveText('Saving…')assertText(loc, 'Saving…', {not: true})
await expect(loc).toHaveText(['Apple', 'Banana'])assertTexts(loc, ['Apple', 'Banana'])
await expect(loc).toHaveAttribute('aria-expanded', 'true')assertAttribute(loc, 'aria-expanded', 'true')
await expect(loc).toBeEmpty() / not.toBeEmpty()assertText(loc, '') / assertText(loc, '', {not: true})

Like Playwright’s expect, these assertions retry until they hold or the timeout runs out, and {not: true} waits for the opposite. A string compares whole unless you pass {exact: false}.

Locators

Playwrightunotest
await locator.boundingBox()boundingBox(loc) — {x, y, width, height}, null when the element is not rendered
locator.evaluate((el, arg) => ..., arg)evaluateOnLocator(loc, (el, arg) => ..., arg)

evaluateOnLocator is an escape hatch, like evaluate: the linter flags it, so prefer a typed getter where one exists.

Downloads

A download is taken after the click that starts it: unotest records downloads from the moment the browser context opens, so no event promise is needed.

Playwrightunotest
const [download] = await Promise.all([page.waitForEvent('download'), btn.click()])click(btn); file = waitForDownload();
page.waitForEvent('download', { timeout: 5000 })waitForDownload({timeoutMs: 5000})
download.suggestedFilename()file.name
await download.saveAs(path)not needed: file.path is the saved file
fs.readFileSync(await download.path(), 'utf8')waitForFile(file.path, 'text') or readFile(file.path)

waitForDownload({name}) takes the first download whose name matches a substring or /regex/. In a run the file is saved with the run and linked under its step in the viewer.

Extensions

An extension loads only into a browser started on a profile: launch.userDataDir plus the --load-extension args in unotest.config. Headless needs channel: "chromium".

Playwrightunotest
context.serviceWorkers()[0] ?? await context.waitForEvent('serviceworker')waitForServiceWorker() — the worker’s URL
new URL(sw.url()).hostextensionId()
serviceWorker.evaluate(fn, arg)evaluateInServiceWorker(js, arg)
page.goto('chrome-extension://<id>/popup.html')goto('chrome-extension://' + extensionId() + '/popup.html')

mockRoute answers the worker’s own fetch only on a profile the browser opens for the first time. On a profile it opened before, the worker reaches the network. A scenario that mocks it needs a fresh launch.userDataDir.

Isolation

A Playwright suite that tests an extension usually opens a persistent context per test on a mkdtemp directory, so that extension storage, service workers and tabs start clean. In unotest that is one config key.

Playwrightunotest
chromium.launchPersistentContext(mkdtempSync(...), { args }) in a per-test fixturelaunch: { profile: 'per-test', args } in unotest.config
context.close() + rm(dir) in the fixture’s teardownnothing to write: the directory goes after a passing test
keeping the directory of a failed test by handkept for you in the run folder, and the run log names it
one launchPersistentContext shared by the whole suitelaunch: { userDataDir }
  • Every test_* gets its own. Each test starts its own browser on its own empty profile, the tests of one file included: a suite of N tests runs on N profiles.
  • One browser per test. newContext() is refused in such a test, as on a shared profile.
  • The machine can override it. UNOTEST_USER_DATA_DIR set in CI gives one shared profile instead, and the run log says so.

Console

In Playwright you collect console messages and page errors with listeners you attach before the page runs. unotest records them for every page from the moment it opens, so a scenario acts first and asks afterwards.

Playwrightunotest
a page.on('console', …) listener, then filtering by m.type() === 'error'consoleEntries({level: 'error'}) → [{level, text, url}]
m.type() === 'warning'level: 'warn'
a page.on('pageerror', …) listenerpageErrors() → [{message, stack}]
expect(errors).toEqual([]) in afterEachassertNoConsoleErrors() — console errors and uncaught ones; {includePageErrors: false} for console errors only
clearing the collected array between phasesbefore = nowMs(), then {since: before} on any of the three
listeners on an extension page (chrome-extension://…)the same calls once it is the active page

The calls answer for the active page; switch with setPage to ask about another tab. A service worker’s console is not a page’s and is not included.