From @playwright/test
For the complete documentation index, see llms.txtThis 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.
| Playwright | unotest |
|---|---|
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 front | setPage(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.
| Playwright | unotest |
|---|---|
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 click | networkEntries({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 sandbox | mockRoute('**/*', { 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 bymockRoute('**/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
baseUrlorigin 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 ismockRoute('https://app.example.com/**', {continue: true}). - Requests already sent.
waitForRequeston a mocked mask finds a request sent before the call, so it can come after the click that sends it — noPromise.alldance. - 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 anowMs()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.
| Playwright | unotest |
|---|---|
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.
toPassretries a thrown assertion;waitUntilfails the step at once when its body throws. Write the check as a condition that returnsfalseinstead of an assert. - Timeout. Without
timeoutMsit waits as long as the other waits of the run (defaultTimeoutMs);intervalMsdefaults 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
letinside thetest_function or the step, and the body writes it. Aletat 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.
| Playwright | unotest |
|---|---|
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.port | url 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.
| Playwright | unotest |
|---|---|
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 issue | the step fails naming every broken field by path: /tags/1: must be string |
- Draft 2020-12. That is what
z.toJSONSchemawrites. A schema that declares another draft is refused rather than checked by the wrong rules. - One file. A
$refinside the schema (#/$defs/tag) is followed; one to another file is refused. Put shared parts under$defs. formatis not checked.email,uuid,date-timeare annotations here; check such a field withassertTrueif 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.
| Playwright | unotest |
|---|---|
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 data | skip('reason', condition) anywhere in the test_* body, inside the step that reads the data too |
process.env.NAME | env('NAME') — null when unset; a secret is refused |
- Inside the test.
skip()works at any depth of atest_*body: next to the steps, inside a step, in anifblock. 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.
| Playwright | unotest |
|---|---|
timeout: 30_000 in playwright.config.ts | testTimeoutMs: 30000 in unotest.config.mjs (default 300000) |
test.setTimeout(90_000) inside a test | setTestTimeout(90000) at the top of the test_* body |
test.slow() | setTestTimeout(…) with the longer budget, spelled out |
npx playwright test --timeout=60000 | UNOTEST_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 aflow_*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.
| Playwright | unotest |
|---|---|
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.send | shared.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({ ... }) fixtures | launch.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.
| Playwright | unotest |
|---|---|
fs.readFileSync(path, 'utf8') | readFile(path) |
fs.readFileSync(path).toString('base64') | readFile(path, {encoding: 'base64'}) |
fs.statSync(path).size | readFileInfo(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.
| Playwright | unotest |
|---|---|
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
| Playwright | unotest |
|---|---|
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.
| Playwright | unotest |
|---|---|
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".
| Playwright | unotest |
|---|---|
context.serviceWorkers()[0] ?? await context.waitForEvent('serviceworker') | waitForServiceWorker() — the worker’s URL |
new URL(sw.url()).host | extensionId() |
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.
| Playwright | unotest |
|---|---|
chromium.launchPersistentContext(mkdtempSync(...), { args }) in a per-test fixture | launch: { profile: 'per-test', args } in unotest.config |
context.close() + rm(dir) in the fixture’s teardown | nothing to write: the directory goes after a passing test |
| keeping the directory of a failed test by hand | kept for you in the run folder, and the run log names it |
one launchPersistentContext shared by the whole suite | launch: { 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_DIRset 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.
| Playwright | unotest |
|---|---|
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', …) listener | pageErrors() → [{message, stack}] |
expect(errors).toEqual([]) in afterEach | assertNoConsoleErrors() — console errors and uncaught ones; {includePageErrors: false} for console errors only |
| clearing the collected array between phases | before = 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.