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?

expect(locator)

LocatorAssertions: state, text, attribute, count, CSS, accessibility, screenshot and aria-snapshot matchers.

Yes

expect(page)

PageAssertions: toHaveTitle, toHaveURL, toHaveScreenshot, toMatchAriaSnapshot.

Yes

expect(response) with an APIResponse

APIResponseAssertions: toBeOK.

No (the response is already complete)

expect(value) with anything else

GenericAssertions: toBe, toEqual, toContain, and the other Jest-style matchers.

No

expect(async () => { ... })

toPass.

Yes, the whole block

expect.poll(fn)

The generic matchers, applied to the value fn returns each time.

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');
});

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

expect: { timeout: 10_000 } in playwright.config.ts. A project can override it with its own expect block.

One assertion

The timeout option: await expect(locator).toBeVisible({ timeout: 15_000 }). The default comes from the configuration.

A group of assertions

expect.configure({ timeout: 10_000 }) returns a new expect with its own defaults (see below).

One assertion, cancellable

The signal option takes an AbortSignal; aborting it fails the assertion without further retries (since 1.62).

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

toBeAttached()

The element is connected to the document or a shadow root (1.33).

attached

toBeChecked()

A checkbox, radio or ARIA checkable element is checked.

checked, indeterminate (1.50)

toBeDisabled(), toBeEnabled()

The element is disabled or enabled.

enabled (on toBeEnabled)

toBeEditable()

The element is an enabled, writable input; throws for elements that cannot be editable.

editable

toBeEmpty()

An editable element has no value or another element has no text.

toBeFocused()

The element has focus.

toBeVisible(), toBeHidden()

Attached and visible (non-empty box, no visibility: hidden), or not attached or not visible. toBeHidden() passes when the locator matches nothing.

visible (on toBeVisible)

toBeInViewport()

The element intersects the viewport (1.31).

ratio

toContainText(), toHaveText()

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).

ignoreCase, useInnerText

toContainClass(), toHaveClass()

Has all of the given classes in any order (1.52), or the class attribute matches the string or pattern.

toHaveAttribute(name, value?)

The attribute equals the value; with only a name it asserts the attribute exists.

ignoreCase

toHaveCount()

The locator resolves to exactly this many nodes.

toHaveCSS(name, value)

The computed style matches.

pseudo (before or after, 1.60)

toHaveId(), toHaveJSProperty()

The DOM id or a JavaScript property of the element matches.

toHaveRole()

The ARIA role equals the string; superclass roles do not match subclass roles.

toHaveAccessibleName(), toHaveAccessibleDescription(), toHaveAccessibleErrorMessage()

The computed accessible name, description or aria-errormessage matches (1.44; the error message since 1.50).

ignoreCase

toHaveValue(), toHaveValues()

The input value matches; every given option of a select multiple is selected (1.23).

toHaveScreenshot()

Two consecutive screenshots are stable and equal the stored baseline.

animations, caret, mask, maxDiffPixels, threshold, stylePath, and more; see Visual Regression

toMatchAriaSnapshot()

The accessibility tree matches the template (1.49).

name for a file-based snapshot

expect(page).toHaveTitle()

The document title matches a string or pattern.

expect(page).toHaveURL()

The URL matches a string (resolved against baseURL), a regular expression, a URLPattern or a predicate function (1.51).

ignoreCase (1.44)

expect(page).toHaveScreenshot(), expect(page).toMatchAriaSnapshot()

The same as the locator versions, for the whole page. The aria snapshot form is equivalent to asserting on body (1.60).

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+\)/);
});

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() or or() before asserting.

  • toBeHidden() versus not.toBeVisible(). They are equivalent. Both pass when nothing matches the locator, which is the right behaviour for "the spinner is gone". Use not.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. toHaveText requires the whole text to match, toContainText a substring. useInnerText: true reads innerText, which respects CSS visibility, instead of textContent.

  • toHaveAttribute(name, '') no longer matches a missing attribute (changed in 1.27). Assert on presence with toHaveAttribute(name) or not.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"

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');
});

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"}

(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/);
});

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

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 });
});

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

timeout

Total time to poll, 5 seconds by default (the expect timeout). 0 disables the timeout.

intervals

Delays between probes in milliseconds: a probe, a wait of the first interval, a probe, and so on; the last value repeats. Defaults to [100, 250, 500, 1000].

message

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);
});

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

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

timeout

Total time to retry. The default is 0, which means no limit other than the test timeout, and toPass does not use the expect timeout. Set a default with expect: { toPass: { timeout } } in the config (1.42).

intervals

Delays between attempts in milliseconds, last value repeating. Defaults to [100, 250, 500, 1000]; configurable with expect: { toPass: { intervals } } (1.44).

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 });
});

Choose between the retry tools by the shape of the check:

Situation Use

The value is on the page

A locator matcher such as toHaveText.

One computed value, checked with one matcher

expect.poll.

Several assertions, or an action on every attempt

toPass.

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]
  • role is 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, and invalid. A bare [checked] means true.

  • - role: text matches 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 - button matches any button; - checkbox matches a checked or unchecked one; a list with a single listitem child 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

contain (default)

Every listed child is present, in order; extra children are allowed. With no listed children, any children match.

equal

The direct children are exactly the listed ones, in order. With no listed children, the node must have none.

deep-equal

The children and all nested descendants match exactly. - /children: contain on a nested node relaxes this for that node.

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"
  `);
});

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

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'] } },
  ],
});

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

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.
-  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"
+  `);

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

depth

Limits how deep the tree is serialised (1.59). Nodes deeper than the limit are shown without their children.

mode

'default' or 'ai'. The AI mode adds element references such as [ref=e2] and [cursor=pointer], includes the contents of iframes, does not wait for the element to exist and throws if the locator matches nothing (1.59).

boxes

When true, appends [box=x,y,width,height] in viewport CSS pixels (1.60).

timeout, signal

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+\]/);
});

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

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

role

The ARIA role, or "text" for a static text fragment.

name

The accessible name, when there is one.

text

The text content when it is the only child, or the content of a text fragment.

children

Child nodes and text fragments.

checked, disabled, expanded, active, invalid, level, pressed, selected

State flags and values.

url, placeholder, and similar

Extra element properties.

ref, cursor

Element reference and "pointer" for clickable elements, in AI mode.

box

x, y, width and height when boxes is set.

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

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

--update-snapshots=missing makes the run pass when it only created missing snapshots; the mode used without the flag is now called default and still fails such tests. Elements inside hidden iframes count as hidden for toBeVisible(). page.getByRef() resolves references from AI-mode snapshots.

1.63

locator.ariaSnapshotJSON() and page.ariaSnapshotJSON().

1.62

The signal option on web-first assertions, to cancel with an AbortSignal.

1.61

expect.soft.poll(...).

1.60

expect(page).toMatchAriaSnapshot(); the boxes option on ariaSnapshot(); the pseudo option on toHaveCSS(); Locator.ariaRef() removed. Failed assertions attach the aria snapshot of the receiver to the error context.

1.59

page.ariaSnapshot() and the depth and mode options.

1.52

toContainClass(); /children and /url in aria snapshots.

1.51

toHaveURL() accepts a predicate.

1.50

File-based aria snapshots (name), pathTemplate, toHaveAccessibleErrorMessage(), indeterminate for toBeChecked().

1.49

expect(locator).toMatchAriaSnapshot().

1.44

toHaveRole(), toHaveAccessibleName(), toHaveAccessibleDescription(); expect.toPass.intervals in the config; ignoreCase for toHaveURL().

1.42

expect.toPass.timeout in the config.

1.39

expect.extend with mergeExpects.