Assert with accessibility — ARIA snapshots and a11y matchers

Use accessibility-aware matchers to lock down structure and catch a11y regressions with clear failure messages.

You already locate elements by ARIA roles (getByRole('button', { name: 'Add to cart' })). That's a great first step, but Playwright also provides accessibility-first assertions.

  1. toMatchAriaSnapshot locks down the structure of an entire region in one assertion.
  2. Dedicated a11y matchers check a single element and fail with a clear message when its accessibility breaks, instead of a generic "couldn't locate" timeout somewhere else.

Snapshot the whole structure with toMatchAriaSnapshot

For a region of the page, toMatchAriaSnapshot captures the entire accessibility tree in a YAML-like format. It's the closest thing Playwright offers to a "structure regression test". It doesn't break on CSS tweaks, but it does fail when meaningful structure changes (a heading disappears, an element is renamed, the order shifts).

await expect(page).toMatchAriaSnapshot(`
  - heading /Playwright enables reliable end-to-end/ [level=1]
  - link "Get started":
    - /url: /docs/intro
  - link "Star microsoft/playwright on GitHub":
    - /url: https://github.com/microsoft/playwright
  - link /[\\d]+k\\+ stargazers on GitHub/
`);

Snapshots can be strict (exact text) or flexible. The quickest way to create them is codegen.

Codegen snapshot

The next exercise is about picking the right level of strictness for each item.

Inline exercise

Write the snapshot for this regionSelect this container via test id: aria-snapshot-exercise

The region below has four pieces of structure: a heading, a paragraph, and two buttons. Write a toMatchAriaSnapshot that locks down the parts you actually care about:

  • Both buttons must keep their literal names (Submit and Undo).
  • The heading must exist, but the wording will change.
  • The paragraph must exist, but the wording will change.

Subscribe to updates

One email a week with the most useful Playwright tips. No spam, ever.

▸ Show how

Literal strings stay strict. Omit other properties to be flexible or use /.+/ as a regex placeholder when you want to lock down structure but ignore exact text.

await expect(
  page.getByRole("region", { name: "Newsletter form" }),
).toMatchAriaSnapshot(`
  - heading [level=2]
  - paragraph
  - button "Submit"
  - button "Undo"
`);

The buttons are the part you care about. Rename one and a real user flow breaks. The heading and paragraph are content the marketing team will edit weekly, so locking down their exact text would just generate noise.

Now, sometimes you don't want to maintain these long YAML definitions or even think about them.

For these cases, call toMatchAriaSnapshot() without an inline string and Playwright stores the expected accessibility tree in a .aria.yml file next to the test.

await expect(container.getByLabel("Newsletter form")).toMatchAriaSnapshot();

If you now run npx playwright test with the --update-snapshots flag, Playwright writes and updates the YAML snapshots for you.

$ npx playwright test tests/inline.spec.ts --update-snapshots

Running 1 tests using 1 workers
[chromium] › tests/inline.spec.ts:3:5 › aria-snapshot
A snapshot is generated at tests/inline.spec.ts-snapshots/aria-snapshot-1.aria.yml.
  1 passed (6.8s)

Per-element accessibility matchers

To check one element at a time, there are four matchers.

Accessibility
toHaveRoleARIA role exposed to assistive tech
toHaveAccessibleNamewhat a screen reader announces
toHaveAccessibleDescriptionoften from aria-describedby
toHaveAccessibleErrorMessageoften from aria-errormessage
Warning

If your locator already encodes the accessible name (e.g. getByRole("button", { name: "Close" })), asserting toHaveAccessibleName("Close") on it adds nothing. The locator wouldn't match without that name in the first place.

Use these matchers when you want a focused a11y check. Don't duplicate what your functional locators already cover.

toHaveAccessibleDescription is the most useful of the four. You almost never locate by description, so asserting the helper text connected via aria-describedby tests something the locator didn't already check.

await expect(
  page.getByRole("textbox", { name: "Email" }),
).toHaveAccessibleDescription("We'll never share your email.");
Inline exercise

Assert the description a screen reader announcesSelect this container via test id: accessible-description-exercise

Click Validate without typing anything. The error message is connected to the input via the aria-describedby attribute so that a screen reader can announce it.

Write an assertion that confirms the input's accessible description is Please enter your email address. after clicking the "Validate" button.

▸ Show how

The locator checks the label (Email address), and the assertion checks the description. They're two different pieces of accessibility information.

const container = page.getByTestId('accessible-description-exercise');
await container.getByRole('button', { name: 'Validate' }).click();
await expect(
  container.getByLabel('Email address')
).toHaveAccessibleDescription('Please enter your email address.');

Better failure messages

One of the strongest arguments for toHaveAccessibleName and toHaveRole is how your tests fail when a11y regresses.

Imagine an icon-only × close button.

<button aria-label="Close">×</button>

Your functional tests use page.getByRole("button", { name: "Close" }).click() to dismiss a dialog. Someone deletes the aria-label in a refactor. The button still looks and works the same, but its accessible name changes from "Close" to "×", which might be read out loud as "multiplication sign".

Every functional test that relies on the role + name locator combination times out with a generic element not found error and you have to dig to figure out what went missing. A single dedicated a11y test, located by something stable (a test id or a positional locator) and asserting toHaveAccessibleName, fails in one line with the exact diff:

await expect(page.getByTestId("close-btn")).toHaveAccessibleName("Close");

// Error shown if the accessible name doesn't match
Expected: "Close";
Received: "×";

It's the same problem, but it comes with a better error message.

Don't use toHaveAccessibleName() to duplicate what's already in your locator definition. Use it as an additional guard.

When to reach for which

Honestly, I primarily use user-first locators paired with an occasional toMatchAriaSnapshot. The other assertions have their place when accessibility plays a big role in your product.

Structure
whole region
toMatchAriaSnapshot

Locks down the structure of a region (forms, navigation, dialogs, etc.) without coupling to visual design.

Description
helper text
toHaveAccessibleDescription

Checks helper text or error messages tied via aria-describedby. Genuinely complementary to role-based locators.

Name & role
single element
toHaveAccessibleName / toHaveRole

Focused a11y regression tests on stable locators (test ids, positional). Fail with a clear diff when a name silently regresses.


Hands on

Practice accessibility assertions

Exercise 1 of 2

Lock down the search page sidebars

  1. Open /search/. The page exposes two labelled navs: "Collections" and "Sort by".
  2. Locate the main element and use toMatchAriaSnapshot to assert that the element includes the Collections and Sort by navigation and the product list (the individual products don't matter).
  3. Re-run the test. It should pass.
Tip

On mobile, these two navigations are select boxes. If you want to skip the test on mobile, go ahead...

if (isMobile) test.skip();

But maybe you want to figure it out!

Exercise 2 of 2

Check the main nav

And while we're at it... Let's take an ARIA snapshot of the main navigation, too!