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 role ARIA (getByRole('button', { name: 'Add to cart' })). That's a great first step, but there are more accessibility-first assertions.

  1. toMatchAriaSnapshot — locks down the structure of an entire region in one assertion.
  2. Dedicated a11y guards — focused checks that fail with a clear message when the accessibility contract regresses, instead of a generic "couldn't locate" timeout somewhere downstream.

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

Snapshots can be strict (exact text) or flexible. 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 contract 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 of the contract you care about — rename one and a real user flow breaks. The heading and paragraph are content the marketing team will edit weekly; locking down their exact text would just generate noise.

Note

ARIA snapshots for larger regions can grow long. Call toMatchAriaSnapshot() without an inline string and Playwright stores the expected tree in a .aria.yml file next to the test — regenerate it with --update-snapshots, just like screenshots.

Per-element accessibility matchers

For one element at a time, four matchers are available.

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 — getByRole("button", { name: "Close" }) — asserting toHaveAccessibleName("Close") on it adds nothing. The locator wouldn't match without that name in the first place. Reach for these matchers when you want a focused a11y guard, not as duplicate checks next to functional locators.

toHaveAccessibleDescription is the per-element matcher that does the most honest work: you almost never locate by description, so asserting the helper text tied via aria-describedby actually tests something the locator didn't already enforce.

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 wires itself to the input via aria-describedby — exactly the helper text a screen reader announces after the label.

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

Show how

The locator pins the label (Email address); the assertion pins the description. Two different parts of the a11y contract, neither duplicating the other.

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

The failure-message angle

One of the strongest argument for toHaveAccessibleName and toHaveRole is the 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 works visually but its accessible name silently changes from "Close" to "×".

Every functional test that relies on the role + name locator now times out with a generic "element not found" and you have to dig to figure out why. 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:

Expected: "Close"
Received: "×"

Same regression. Different diagnosis. The dedicated guard is the toHaveAccessibleName use case that isn't a duplicate of your locator. It's not next to your click; it's a focused regression test for the a11y contract itself.

These matchers are auto-retrying like every other web-first assertion, so the patterns from the previous lesson — .not, expect.soft, custom messages, configurable timeouts — all apply unchanged.

When to reach for which

So, when should you reach for which. Honestly, I'm primarily using the user-first locators paired with occasional toMatchAriaSnapshot. The other assertions do have their place in though, when accessibility plays a bit role in your product.

Structure
whole region
toMatchAriaSnapshot

Locks down the structure of a region — forms, navigation, dialogs — 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 (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, 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 already at adding quick structural assertions... Let's take an ARIA snapshot of the main navigation, too!