Playwright Assertions
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
An assertion is where a browser test decides whether the application did the right thing. Playwright’s answer to the
timing problem of every UI test is the web-first assertion: instead of reading a value once and comparing it,
expect(locator) keeps re-reading the page until the condition holds or a timeout expires. This page covers every
family of assertion that @playwright/test ships, how to combine and extend them, and the aria snapshot, which asserts
the shape of the accessibility tree. It is written for Playwright 1.64.1 (Node.js) and was checked on 25 October 2026;
the matchers are the same in the Python, Java and .NET bindings, whose examples were run at 1.63 (see
Playwright with Python, Java and .NET). Every TypeScript
example on this page was run against the Bookshelf
application in headless Chromium and WebKit (18 tests in each, all passing); the failure messages quoted below were
captured from deliberately failing tests.
Assertions use locators, which are explained in Locators, Actions & Auto-waiting. The test ids and routes come from the example application page, and the choice between asserting on user-visible behaviour and on implementation details is discussed in Test Design & Page Objects.
Web-first assertions and auto-retry
expect is imported from @playwright/test. Calling expect(value) returns an object of matchers; what you pass in
decides which matchers exist:
| You call | You get | Retries? |
|---|---|---|
|
|
Yes |
|
|
Yes |
|
|
No (the response is already complete) |
|
|
No |
|
|
Yes, the whole block |
|
The generic matchers, applied to the value |
Yes |
A retrying matcher runs a loop. It resolves the locator again on every iteration (so a re-rendered element is found
afresh), evaluates the condition inside the page and, when the condition is false, waits and tries again until the
assertion timeout passes. Because the loop is asynchronous, a retrying assertion must be awaited; a missing await is
the most common reason a check silently never runs. This is the property that removes the need for fixed sleeps, as
discussed in Test Design & Page Objects.
import { test, expect } from './fixtures';
test.beforeEach(async ({ request }) => {
await request.post('/api/test/reset');
});
test('web-first assertions retry until the page settles', async ({ page }) => {
await page.goto('/books');
await expect(page).toHaveTitle(/Catalogue/);
await expect(page).toHaveURL('/books');
await expect(page.getByTestId('result-count')).toHaveText('24 books found');
await expect(page.getByTestId('book-card')).toHaveCount(6);
await page.getByTestId('search-input').fill('quiet');
await page.getByRole('button', { name: 'Search' }).click();
await expect(page).toHaveURL(/q=quiet/);
await expect(page.getByTestId('result-count')).toContainText('1 book');
await expect(page.getByTestId('book-title')).toHaveText('The Quiet Compiler');
await expect(page.getByTestId('book-card')).toHaveAttribute('data-book-id', '1');
});
Source: Playwright: Assertions
The test above does not wait for the search to finish before it asserts. After the click, the page navigates and the
catalogue is re-rendered; toHaveURL and toContainText keep polling until the new state appears. The example’s
imports come from a small fixtures.ts that re-exports test and a customised expect (shown later under custom
matchers); the built-in @playwright/test import behaves identically for everything except the custom matcher.
The assertion timeout
The retry window is the expect timeout, 5 seconds by default. It is independent of the test timeout (30 seconds by
default) and of the action timeout, which governs click() and friends; the three are explained in
Configuration, Fixtures & Projects. If the
test timeout is shorter than the remaining expect timeout, the test fails at the test timeout instead.
| Where | How |
|---|---|
Whole project |
|
One assertion |
The |
A group of assertions |
|
One assertion, cancellable |
The |
A long assertion timeout is a smell. If a check needs 30 seconds, the application is slow, a request is stuck or the locator is wrong; raising the limit hides the cause. Use a longer timeout only for a step that is slow by nature, such as a report that takes several seconds to generate, and give that single assertion the option.
The auto-retrying matchers
All of these are asynchronous and retry. Every one accepts the timeout and signal options; the extra options are
listed in the last column. A version in parentheses is the release that introduced the matcher or the option, when it is
later than the first web-first assertions of 1.8.
| Matcher | Passes when | Extra options |
|---|---|---|
|
The element is connected to the document or a shadow root (1.33). |
|
|
A checkbox, radio or ARIA checkable element is checked. |
|
|
The element is disabled or enabled. |
|
|
The element is an enabled, writable input; throws for elements that cannot be editable. |
|
|
An editable element has no value or another element has no text. |
|
|
The element has focus. |
|
|
Attached and visible (non-empty box, no |
|
|
The element intersects the viewport (1.31). |
|
|
Contains or matches the text. A string is matched after normalising whitespace; a regular expression is matched as written. An array asserts a list of elements (see below). |
|
|
Has all of the given classes in any order (1.52), or the |
|
|
The attribute equals the value; with only a name it asserts the attribute exists. |
|
|
The locator resolves to exactly this many nodes. |
|
|
The computed style matches. |
|
|
The DOM id or a JavaScript property of the element matches. |
|
|
The ARIA role equals the string; superclass roles do not match subclass roles. |
|
|
The computed accessible name, description or |
|
|
The input value matches; every given option of a |
|
|
Two consecutive screenshots are stable and equal the stored baseline. |
|
|
The accessibility tree matches the template (1.49). |
|
|
The document title matches a string or pattern. |
|
|
The URL matches a string (resolved against |
|
|
The same as the locator versions, for the whole page. The aria snapshot form is equivalent to asserting on |
Lists, options and pitfalls
toHaveText and toContainText accept an array when the locator resolves to several elements. toHaveText([...])
requires the same number of elements and a match for each, in order. toContainText([...]) requires only that the given
texts match a subset of the elements, in order. The locator must point at the items, not at the container.
test('lists, negation and options', async ({ page }) => {
await page.goto('/books');
const titles = page.getByTestId('book-title');
await expect(titles).toHaveText([
'The Quiet Compiler',
'Rivers of Static',
'A Field Guide to Clouds',
'Salt and Signal',
'The Lantern Index',
'Small Systems, Big Days',
]);
await expect(titles).toContainText(['Quiet', 'Signal']);
await expect(titles.first()).toHaveText('the quiet compiler', { ignoreCase: true });
await expect(page.getByTestId('empty-state')).not.toBeVisible();
await expect(page.getByTestId('empty-state')).toBeHidden();
await expect(page.getByTestId('empty-state')).not.toBeAttached();
await expect(page.getByRole('searchbox', { name: 'Search books' })).toBeEditable();
await expect(page.getByRole('searchbox', { name: 'Search books' })).toBeEmpty();
await expect(page.getByRole('searchbox', { name: 'Search books' })).toHaveAccessibleName('Search books');
await expect(page.getByTestId('theme-toggle')).toHaveRole('button');
});
test('state that changes in the page', async ({ page }) => {
await page.goto('/');
await expect(page.locator('html')).not.toHaveAttribute('data-theme', 'dark');
await page.getByTestId('theme-toggle').click();
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
await expect(page.locator('body')).toHaveCSS('color', /rgb\(\d+, \d+, \d+\)/);
});
Source: Playwright: LocatorAssertions
Several details are easy to get wrong:
-
toBeVisible()and strict mode. An assertion on a locator that matches several elements fails with a strict-mode violation, exactly as an action does. To ask "is at least one of these visible", narrow the locator with.first()oror()before asserting. -
toBeHidden()versusnot.toBeVisible(). They are equivalent. Both pass when nothing matches the locator, which is the right behaviour for "the spinner is gone". Usenot.toBeAttached()when the element must be absent from the DOM, not merely hidden. -
Text matching. A string expected value normalises whitespace and line breaks in both the actual text and the expected string; a regular expression does not.
toHaveTextrequires the whole text to match,toContainTexta substring.useInnerText: truereadsinnerText, which respects CSS visibility, instead oftextContent. -
toHaveAttribute(name, '')no longer matches a missing attribute (changed in 1.27). Assert on presence withtoHaveAttribute(name)ornot.toHaveAttribute(name). -
Open shadow roots are pierced by the text matchers; closed shadow roots are not reachable.
Negation
Add .not before any matcher to assert the opposite: await expect(locator).not.toContainText('error'). A negated
retrying assertion retries until the condition is false, so not.toBeVisible() waits for something to disappear, and it
passes immediately if the element is already gone. Negation is also what makes the failure messages say "Expected: not".
The same .not works on generic, response and custom matchers.
Custom messages
Pass a message as the second argument of expect (or expect.soft). Reporters show it for passing and failing
assertions, and it replaces the generic first line of the error, which is helpful when a locator is hard to read:
test('a custom message names the check', async ({ page }) => {
await page.goto('/books/1');
await expect(page.getByTestId('review-list'), 'reviews are listed for a seeded book').toBeVisible();
await expect.soft(page.getByTestId('average-rating'), 'average of the seeded reviews').toContainText('4.5');
});
When a message is set and the assertion fails, the error begins with the message. This failure, with a shortened timeout, was captured from a test that expects "25 books found" in a catalogue of 24:
Error: the catalogue count is shown
expect(locator).toHaveText(expected) failed
Locator: getByTestId('result-count')
Expected: "25 books found"
Received: "24 books found"
Timeout: 1000ms
Call log:
- the catalogue count is shown getByTestId('result-count') with timeout 1000ms
- waiting for getByTestId('result-count')
11 × locator resolved to <p aria-live="polite" data-testid="result-count">24 books found</p>
- unexpected value "24 books found"
Source: Playwright: Assertions
The call log is the diagnostic to read first: it shows how many times the locator resolved, to what element, and the
last unexpected value. A locator that never resolves shows waiting for getByTestId(...) with no resolution lines,
which means the selector is wrong or the element never rendered.
Generic, non-retrying assertions
expect(value) with any plain value gives the Jest-compatible matchers: toBe, toEqual, toStrictEqual,
toMatchObject, toContain, toContainEqual, toHaveLength, toHaveProperty, toMatch, toBeCloseTo,
toBeGreaterThan and toBeLessThan (and their OrEqual forms), toBeDefined, toBeUndefined, toBeNull,
toBeNaN, toBeTruthy, toBeFalsy, toBeInstanceOf and toThrow. They evaluate once and never retry. They are the
right tool for data that is already settled, such as an API response body or a value computed in the test, and the wrong
tool for anything the page produces asynchronously: expect(await locator.textContent()).toBe('x') reads the text once
and is the classic cause of flaky tests. Use expect(locator).toHaveText('x') instead.
The asymmetric matchers can be nested inside toEqual and toMatchObject to relax a comparison: expect.any(Class),
expect.anything(), expect.arrayContaining(), expect.arrayOf(), expect.closeTo(), expect.objectContaining(),
expect.stringContaining() and expect.stringMatching().
test('generic assertions and asymmetric matchers do not retry', async ({ request }) => {
const response = await request.get('/api/books?q=quiet');
const body = await response.json();
expect(body.total).toBe(1);
expect(body.items).toHaveLength(1);
expect(body.items[0]).toMatchObject({ id: 1, title: 'The Quiet Compiler' });
expect(body).toEqual(
expect.objectContaining({
page: 1,
items: expect.arrayContaining([expect.objectContaining({ author: expect.stringContaining('Velde') })]),
}),
);
expect(body.items[0].price).toBeCloseTo(9.99, 2);
expect(body.items.map((b: { genre: string }) => b.genre)).toContain('Technology');
});
Source: Playwright: GenericAssertions
Playwright’s expect is not the expect library that Jest uses, although the generic matchers have the same names.
The official documentation warns that the library is not fully integrated with the Playwright test runner, so always
import expect from @playwright/test.
API response assertions
expect(response).toBeOK() asserts that an APIResponse, from request.get() or page.request, has a status in the
range 200 to 299. It does not retry, because the response is complete. not.toBeOK() asserts the opposite. The failure
message includes the request line, the response headers and the response body, which is usually enough to diagnose the
problem without re-running:
test('API response assertions', async ({ request }) => {
const ok = await request.get('/api/books/1');
await expect(ok).toBeOK();
const missing = await request.get('/api/books/9999');
await expect(missing).not.toBeOK();
expect(missing.status()).toBe(404);
});
Captured from a test asserting toBeOK() on the missing book:
Error: expect(response).toBeOK() failed
Call log:
→ GET http://localhost:3913/api/books/9999
accept: */*
accept-encoding: gzip,deflate,br
← 404 Not Found
content-type: application/json
Response text:
{"error":"not found"}
Source: Playwright: API testing
(Some header lines were removed for brevity.) Assert on the exact status with expect(response.status()).toBe(404) when
the code matters, and on the body with generic matchers. The APIRequestContext itself, including
failOnStatusCode, is covered in
Network Mocking, Clock & API Testing.
Soft assertions
A failed assertion normally throws and ends the test. A soft assertion records the failure, marks the test as failed
and lets the test keep going, so one run reports every problem on a page. Use expect.soft(...) in place of
expect(...); it takes the same matchers, messages and options. Soft assertions are a feature of the Playwright test
runner and do not work outside it.
test('soft assertions report every failure and keep going', async ({ page }) => {
await page.goto('/books/1');
await expect.soft(page.getByRole('heading', { level: 1 })).toHaveText('The Quiet Compiler');
await expect.soft(page.getByTestId('book-price')).toContainText('$');
await expect.soft(page.getByTestId('review')).toHaveCount(2);
// The checks above do not stop the test; this one decides whether to go on.
expect(test.info().errors).toHaveLength(0);
await page.getByTestId('signin-prompt').getByRole('link', { name: 'Sign in' }).click();
await expect(page).toHaveURL(/\/signin/);
});
Source: Playwright: Soft assertions
test.info().errors lists every failure recorded so far, so asserting it is empty turns the next step into a gate:
when earlier soft checks failed, the test stops before doing anything that depends on them. This failing test, with
shortened timeouts, shows that both soft failures are reported and the following hard assertion still runs:
1) soft failures are all reported
Error: count
expect(locator).toHaveText(expected) failed
Locator: getByTestId('result-count')
Expected: "1 book found"
Received: "24 books found"
Timeout: 500ms
Error: cards
expect(locator).toHaveCount(expected) failed
Locator: getByTestId('book-card')
Expected: 9
Received: 6
Timeout: 500ms
Source: Playwright: Soft assertions
Soft assertions are a good fit for checking many independent facts on one page, such as a summary panel. They are a poor
fit for steps where later actions assume the earlier check passed: each failed soft assertion still waits for its full
timeout, so a page that is entirely broken costs one timeout per soft check unless a gate stops the test.
expect.soft.poll(...) combines soft behaviour with polling (1.61), and expect.configure({ soft: true }) makes a whole
expect instance soft.
Configured expect instances
expect.configure(options) returns a new expect with its own defaults. The options are timeout, soft and message.
Use it to create a "slow" or "soft" variant once and share it:
test('expect.configure gives a slower or softer expect', async ({ page }) => {
await page.goto('/books');
const slowExpect = expect.configure({ timeout: 10_000 });
await slowExpect(page.getByTestId('result-count')).toHaveText('24 books found');
const softExpect = expect.configure({ soft: true });
await softExpect(page.getByTestId('book-card')).toHaveCount(6);
await expect(page.getByTestId('book-card')).toHaveCount(6, { timeout: 2_000 });
});
Source: Playwright: expect.configure
expect.configure({ soft: true }) also chains with expect.poll, so softExpect.poll(...) is a soft polling assertion.
Polling with expect.poll
Not every condition is a locator. expect.poll(fn, options) calls an async function repeatedly and applies a generic
matcher to the returned value until it passes. It is the right tool for a value that changes outside the page, such as
an API that eventually reports a state, a counter, or a file on disk.
| Option | Meaning |
|---|---|
|
Total time to poll, 5 seconds by default (the expect timeout). |
|
Delays between probes in milliseconds: a probe, a wait of the first interval, a probe, and so on; the last value
repeats. Defaults to |
|
A custom message for reports and failures. |
test('expect.poll retries a value that is not a locator', async ({ page, request }) => {
await page.goto('/signin');
await page.getByLabel('Username').fill('reader');
await page.getByLabel('Password').fill(process.env.BOOKSHELF_READER_PASSWORD ?? 'reader-local-only');
await page.getByTestId('signin-submit').click();
await expect.poll(async () => {
const response = await page.request.get('/api/session');
return (await response.json()).role;
}, { message: 'the session cookie is accepted by the API', timeout: 10_000 }).toBe('reader');
await expect
.poll(async () => (await page.request.get('/api/reviews?bookId=1')).status(), { intervals: [100, 250, 500] })
.toBe(200);
await expect.soft.poll(async () => (await request.get('/api/session')).status()).toBe(200);
});
Source: Playwright: expect.poll
The poller supports the generic matchers (toBe, toContain, toEqual, …) but not the locator matchers, which
already retry. A poll failure shows the last value:
Error: value should become 2
expect(received).toBe(expected) // Object.is equality
Expected: 2
Received: 1
Call Log:
- Timeout 500ms exceeded while waiting on the predicate
Source: Playwright: expect.poll
Retrying a block with expect.toPass
expect(async () => { ... }).toPass() re-runs a whole block until every assertion inside passes. Use it when the
condition needs several steps or several assertions, or when the block must perform an action (such as a request) on
each attempt. toPass retries on any thrown error, including from non-retrying assertions.
| Option | Meaning |
|---|---|
|
Total time to retry. The default is |
|
Delays between attempts in milliseconds, last value repeating. Defaults to |
test('expect.toPass retries a whole block', async ({ page }) => {
await page.goto('/signin');
await page.getByLabel('Username').fill('reader');
await page.getByLabel('Password').fill(process.env.BOOKSHELF_READER_PASSWORD ?? 'reader-local-only');
await page.getByTestId('signin-submit').click();
await page.goto('/books/3');
await page.getByLabel('Rating').selectOption('5');
await page.getByLabel('Your review').fill('Worth every page of it.');
await page.getByTestId('review-submit').click();
await expect(async () => {
const response = await page.request.get('/api/reviews?bookId=3');
expect(response.status()).toBe(200);
const reviews = await response.json();
expect(reviews.map((r: { text: string }) => r.text)).toContain('Worth every page of it.');
}).toPass({ intervals: [100, 250, 500], timeout: 10_000 });
});
Source: Playwright: expect.toPass
Choose between the retry tools by the shape of the check:
| Situation | Use |
|---|---|
The value is on the page |
A locator matcher such as |
One computed value, checked with one matcher |
|
Several assertions, or an action on every attempt |
|
The value is already known |
A generic matcher. |
Keep the block small and free of side effects that cannot be repeated: a retried block that clicks "Place order" can
place several orders. Do not wrap clicks and fills in toPass to compensate for missing waits; locator actions already
auto-wait.
Custom matchers
expect.extend(matchers) returns a new expect with extra matchers, which TypeScript sees with correct types when the
matchers are declared in the object literal. A matcher is an async function whose first argument is the received value
and which returns { pass, message, name, expected, actual }. Inside it, this.isNot tells whether .not was used,
this.utils offers formatting helpers (matcherHint, printExpected, printReceived) and the timeout option
reflects the configured expect timeout (since 1.45). The simplest way to keep retry behaviour is to delegate to a
built-in matcher and translate its result, as below.
import { expect as baseExpect, test as base } from '@playwright/test';
import type { Locator } from '@playwright/test';
export const test = base.extend({});
export const expect = baseExpect.extend({
// Asserts the data-book-id attribute of a book card, retrying like a built-in matcher.
async toHaveBookId(locator: Locator, expected: number, options?: { timeout?: number }) {
const assertionName = 'toHaveBookId';
let pass: boolean;
let matcherResult: any;
try {
const expectation = this.isNot ? baseExpect(locator).not : baseExpect(locator);
await expectation.toHaveAttribute('data-book-id', String(expected), options);
pass = true;
} catch (e: any) {
matcherResult = e.matcherResult;
pass = false;
}
if (this.isNot) {
pass = !pass;
}
const message = () =>
this.utils.matcherHint(assertionName, undefined, undefined, { isNot: this.isNot }) +
'\n\n' +
`Locator: ${locator}\n` +
`Expected${this.isNot ? ' not' : ''}: ${this.utils.printExpected(expected)}\n` +
(matcherResult ? `Received: ${this.utils.printReceived(matcherResult.actual)}` : '');
return { message, pass, name: assertionName, expected, actual: matcherResult?.actual };
},
});
test('a custom matcher built with expect.extend', async ({ page }) => {
await page.goto('/books');
await expect(page.getByTestId('book-card').nth(2)).toHaveBookId(3);
await expect(page.getByTestId('book-card').nth(2)).not.toHaveBookId(4);
});
The failure output of a custom matcher uses the hint the matcher builds:
Error: expect(received).toHaveBookId(expected)
Locator: getByTestId('book-card').first()
Expected: 7
Received: "1"
A matcher that wraps a built-in one is still limited by the built-in’s timeout, so pass the options through, as the
example does. Matchers declared in separate files or packages are combined with mergeExpects (and the matching
fixtures with mergeTests):
import { mergeTests, mergeExpects } from '@playwright/test';
import { test as dbTest, expect as dbExpect } from 'database-test-utils';
import { test as a11yTest, expect as a11yExpect } from 'a11y-test-utils';
export const expect = mergeExpects(dbExpect, a11yExpect);
export const test = mergeTests(dbTest, a11yTest);
The merge example was not run: database-test-utils and a11y-test-utils are placeholder module names from the
official documentation, not real packages. mergeExpects itself is declared in the installed 1.64.1 type definitions.
Aria snapshots
Locator assertions check one fact at a time. An aria snapshot checks the structure of a region at once: it compares the
accessibility tree of a page or element with a YAML template, so a test can say "this region is a heading, a search form
and a list of links" in a few lines and still fail if the structure regresses. Because the tree is built from roles and
accessible names, the template also exercises accessibility: a button that lost its name, or a heading that became a
div, no longer matches. Aria snapshots arrived in Playwright 1.49 (toMatchAriaSnapshot).
The template format
Each line of the template is a YAML list item describing one accessible node:
- role "name" [attribute=value]
Source: Playwright: Aria snapshots
-
roleis the ARIA or HTML role:heading,link,button,list,listitem,navigation,textbox, … -
"name"is the accessible name. A quoted string is matched exactly (after whitespace normalisation); a/pattern/is a regular expression. -
[attribute=value]constrains a state:checked,disabled,expanded,level,pressed,selected, andinvalid. A bare[checked]means true. -
- role: textmatches the node’s text content,- text: ...is a standalone text node, and- /url: ...asserts a link’s destination, as a string or/regex/(1.52). -
Indentation nests children.
The comparison is case-sensitive and order-sensitive, and the template is a constraint, not a full serialisation:
-
Partial matching by omission. Leave out the name, an attribute or some children and the node still matches. A template of
- buttonmatches any button;- checkboxmatches a checked or unchecked one; alistwith a singlelistitemchild matches a list of many items as long as the item is present. -
Position. A template with a single top-level node can match at any depth inside the target. Several top-level nodes must all be siblings under the same parent, in the same order.
-
Regular expressions for names and text allow dynamic data:
heading /Issues \d+/. -
Whitespace in names and text is normalised before comparison.
The /children property switches how the children of one node are matched:
| Value | Meaning |
|---|---|
|
Every listed child is present, in order; extra children are allowed. With no listed children, any children match. |
|
The direct children are exactly the listed ones, in order. With no listed children, the node must have none. |
|
The children and all nested descendants match exactly. |
The default for every assertion can be set in the configuration with expect: { toMatchAriaSnapshot: { children: 'equal' } };
a /children property in a template always wins. /children and /url were added in 1.52.
Inline snapshots
Pass the template as a string to expect(locator).toMatchAriaSnapshot() or expect(page).toMatchAriaSnapshot(). A
template literal keeps the YAML readable, and the leading indentation is ignored:
test('inline template for the header (partial matching)', async ({ page }) => {
await page.goto('/books');
await expect(page.getByRole('banner')).toMatchAriaSnapshot(`
- banner:
- link "Bookshelf"
- navigation "Main":
- link "Catalogue"
- link "Sign in"
- button "Toggle theme"
`);
});
test('regular expressions, attributes and /url', async ({ page }) => {
await page.goto('/books');
await expect(page.getByRole('main')).toMatchAriaSnapshot(`
- heading "Catalogue" [level=1]
- search:
- searchbox "Search books"
- paragraph: /\\d+ books found/
- list:
- listitem:
- link /Cover of .*/:
- /url: /books/1
`);
});
test('/children: equal rejects extra children', async ({ page }) => {
await page.goto('/books');
const nav = page.getByRole('navigation', { name: 'Main' });
await expect(nav).toMatchAriaSnapshot(`
- navigation "Main":
- /children: equal
- link "Catalogue"
- link "Sign in"
- button "Toggle theme"
`);
await expect(nav).not.toMatchAriaSnapshot(`
- navigation "Main":
- /children: equal
- link "Catalogue"
- link "Sign in"
`);
});
test('the page version of the assertion', async ({ page }) => {
await page.goto('/signin');
await expect(page).toMatchAriaSnapshot(`
- main:
- heading "Sign in" [level=1]
- textbox "Username"
- textbox "Password"
- button "Sign in"
`);
});
Source: Playwright: Snapshot testing
In a TypeScript template literal a backslash must be doubled (\\d), because the string is processed before YAML sees
it. A mismatch prints a diff between the template and the actual tree. This failure was captured from a template that
expected a link named "Log in":
Error: expect(locator).toMatchAriaSnapshot(expected) failed
Locator: getByRole('navigation', { name: 'Main' })
Timeout: 1000ms
- Expected - 2
+ Received + 5
- navigation "Main":
- - link "Catalogue"
- - link "Log in"
+ - link "Catalogue":
+ - /url: /books
+ - link "Sign in":
+ - /url: /signin
+ - button "Toggle theme": Theme
Source: Playwright: Snapshot testing
Like every locator assertion, toMatchAriaSnapshot retries until the timeout, so it waits for the region to render.
Snapshots in separate files
For large regions an inline template makes the test hard to read. Call toMatchAriaSnapshot({ name: 'x.aria.yml' }) and
Playwright stores the snapshot in a file next to the test, in a folder named after it
(example.spec.ts-snapshots/ by default). Because accessibility trees are the same in every browser, one file serves
all projects. Without a name, sequential names are generated. The location is controlled by
expect: { toMatchAriaSnapshot: { pathTemplate } } (1.50); the example configuration below uses
'__aria__/{testFilePath}/{arg}{ext}'.
test('a snapshot stored in its own .aria.yml file', async ({ page }) => {
await page.goto('/books');
await expect(page.getByRole('navigation', { name: 'Main' })).toMatchAriaSnapshot({ name: 'main-nav.aria.yml' });
});
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: '.',
workers: 1, // every test resets the one shared Bookshelf server
expect: {
timeout: 5_000,
toPass: { timeout: 15_000, intervals: [250, 500, 1_000] },
toMatchAriaSnapshot: { pathTemplate: '__aria__/{testFilePath}/{arg}{ext}' },
},
use: {
baseURL: process.env.BASE_URL,
testIdAttribute: 'data-testid',
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'webkit', use: { ...devices['Desktop Safari'] } },
],
});
Source: Playwright: Test configuration
The first run created __aria__/aria.spec.ts/main-nav.aria.yml, with this content:
- navigation "Main":
- link "Catalogue":
- /url: /books
- link "Sign in":
- /url: /signin
- button "Toggle theme": Theme
Source: Playwright: Snapshot testing
Generating and updating snapshots
You rarely write a template from scratch. There are three ways to generate one:
-
Codegen. In the test generator, the "Assert snapshot" action creates an aria snapshot assertion for the selected element, and the "Aria snapshot" tab shows the tree of a picked locator. See Debugging, Tracing & Codegen.
-
An empty template.
toMatchAriaSnapshot('')fills itself in on the next run. -
The runner flag.
npx playwright test --update-snapshots(-u) rewrites snapshots that do not match.
--update-snapshots takes a mode: default (without the flag), changed (the flag without a value), missing, all
and none. In the default mode a missing snapshot is written, but the test that wrote it fails, so a run that created
snapshots never passes silently. missing writes absent snapshots and keeps those tests passing, which lets a CI job
create new snapshots and verify the existing ones in a single run (1.64). all regenerates every snapshot, matching
ones included, and none prevents any update.
Inline templates are source code, so Playwright cannot just overwrite them. With --update-source-method it writes the
update as patch (the default, a unified diff in test-results/rebaselines.patch to apply with git apply), as
3way (merge-conflict markers in the source, to accept or reject) or as overwrite (rewrites the source in place).
.aria.yml files are written directly. This is the output (abridged) of generating a snapshot from an empty template in the
default mode, followed by the patch that was produced:
$ npx playwright test -c ex-assertions/playwright.config.ts gen
✘ 1 [chromium] › gen/gen.spec.ts:3:5 › generate an inline snapshot (5.1s)
New baselines created for:
ex-assertions/gen/gen.spec.ts
git apply ex-assertions/test-results/rebaselines.patch
Error: A snapshot is not provided, generating new baseline.
Source: Playwright: Generating snapshots
- await expect(page.getByRole('main')).toMatchAriaSnapshot('');
+ await expect(page.getByRole('main')).toMatchAriaSnapshot(`
+ - main:
+ - heading "Sign in" [level=1]
+ - text: Username
+ - textbox "Username"
+ - text: Password
+ - textbox "Password"
+ - button "Sign in"
+ `);
Source: Playwright: Snapshot patch files
The generated template is a full serialisation. Trim it before committing: remove the text nodes and details that do
not matter, so the test fails only when something important changes. A snapshot that is accepted without reading is the
main risk of snapshot testing; review every update as you would review code. Running the same test with --update-snapshots=missing --update-source-method=overwrite wrote that
text into the source file in place and the test passed.
Reading the tree programmatically
locator.ariaSnapshot() and page.ariaSnapshot() (1.59 for the page version) return the YAML as a string, which is
useful to explore a page, to log what a template should look like, or to feed to a tool. page.ariaSnapshot() is
equivalent to page.locator('body').ariaSnapshot(). Both accept options:
| Option | Meaning |
|---|---|
|
Limits how deep the tree is serialised (1.59). Nodes deeper than the limit are shown without their children. |
|
|
|
When |
|
Time limit (default no timeout) and cancellation. |
The references from the AI mode are what page.getByRef() resolves (1.64), and what the Playwright MCP server and CLI use
to act on elements; see Playwright MCP and CLI.
test('page.ariaSnapshot() with depth and mode', async ({ page }) => {
await page.goto('/books');
const full = await page.ariaSnapshot();
const shallow = await page.ariaSnapshot({ depth: 2 });
const ai = await page.ariaSnapshot({ mode: 'ai' });
console.log('FULL LINES', full.split('\n').length, 'SHALLOW LINES', shallow.split('\n').length);
console.log(shallow);
console.log(ai.split('\n').slice(0, 12).join('\n'));
expect(shallow.split('\n').length).toBeLessThan(full.split('\n').length);
expect(ai).toMatch(/\[ref=e\d+\]/);
});
Source: Playwright: page.ariaSnapshot()
The run printed 66 lines for the full snapshot of the catalogue and 30 for depth: 2, where each of the six book cards
collapses to a bare listitem. The beginning of the AI-mode output:
- generic [active] [ref=e1]:
- link "Skip to content" [ref=e2] [cursor=pointer]:
- /url: "#main"
- banner [ref=e3]:
- link "Bookshelf" [ref=e4] [cursor=pointer]:
- /url: /
- navigation "Main" [ref=e5]:
- link "Catalogue" [ref=e6] [cursor=pointer]:
- /url: /books
- link "Sign in" [ref=e7] [cursor=pointer]:
- /url: /signin
- button "Toggle theme" [ref=e8] [cursor=pointer]: Theme
Source: Playwright: page.ariaSnapshot()
The JSON form
locator.ariaSnapshotJSON() and page.ariaSnapshotJSON() return the same tree as a JSON value instead of YAML (added in
1.63). It takes the same mode, depth, boxes, timeout and signal options and is the better choice when a test
or a tool needs to walk the tree with code. The result is a list of nodes; each node has some of these properties:
| Property | Meaning |
|---|---|
|
The ARIA role, or |
|
The accessible name, when there is one. |
|
The text content when it is the only child, or the content of a text fragment. |
|
Child nodes and text fragments. |
|
State flags and values. |
|
Extra element properties. |
|
Element reference and |
|
|
test('ariaSnapshotJSON()', async ({ page }) => {
await page.goto('/books');
const nav = await page.getByRole('navigation', { name: 'Pagination' }).ariaSnapshotJSON();
console.log(JSON.stringify(nav, null, 2));
expect(nav[0]).toMatchObject({ role: 'navigation', name: 'Pagination' });
const pageTree = await page.ariaSnapshotJSON({ depth: 1 });
console.log(JSON.stringify(pageTree, null, 1).slice(0, 700));
});
The output for the pagination control:
[
{
"role": "navigation",
"name": "Pagination",
"children": [
"Previous Page 1 of 4",
{
"role": "link",
"name": "Next",
"url": "/books?page=2"
}
]
}
]
Both mode and depth exist on ariaSnapshot() and ariaSnapshotJSON() in the 1.64.1 type definitions, and
ariaSnapshotJSON() is a real method on Page and Locator; the older Locator.ariaRef() was removed in 1.60.
When to use an aria snapshot
Aria snapshots complement targeted assertions rather than replacing them. They suit whole components and regions whose structure should not change (navigation, a form, a table of results), and they double as a light accessibility check. They are a poor fit for content that changes on every run unless the template uses regular expressions and partial matching. For accessibility rules beyond structure, such as colour contrast, use an engine like axe, covered in Accessibility Testing. For pixel-level checks use screenshots, discussed in Visual Regression.
Running the examples
All examples live in one project with a configuration that reads BASE_URL. With Bookshelf running on a free port:
PORT=3913 node server.js &
BASE_URL=http://localhost:3913 npx playwright test -c ex-assertions/playwright.config.ts
Source: Playwright: Running tests
The result was 36 passed (18 tests in Chromium and 18 in WebKit, headless). The configuration sets workers: 1 because
every test calls /api/test/reset, which also clears all sessions; with several workers, one test’s reset can sign out
another test that is half-way through a sign-in. That failure was observed once, in the toPass example under WebKit,
before the setting was added, and it did not recur after it. Firefox was not run on this host: the local Firefox build did not launch. Shared state
on one server is a property of the example application; in a real suite, give each worker its own data or user, as
described in Authentication & Isolation.
What changed recently
| Version | Change |
|---|---|
1.64 |
|
1.63 |
|
1.62 |
The |
1.61 |
|
1.60 |
|
1.59 |
|
1.52 |
|
1.51 |
|
1.50 |
File-based aria snapshots ( |
1.49 |
|
1.44 |
|
1.42 |
|
1.39 |
|