toMatchAriaSnapshotLocks down the structure of a region — forms, navigation, dialogs — without coupling to visual design.
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.
toMatchAriaSnapshot — locks down the structure of an entire region in one assertion.toMatchAriaSnapshotFor 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.
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:
Submit and Undo).One email a week with the most useful Playwright tips. No spam, ever.
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.
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.
For one element at a time, four matchers are available.
toHaveRoleARIA role exposed to assistive techtoHaveAccessibleNamewhat a screen reader announcestoHaveAccessibleDescriptionoften from aria-describedbytoHaveAccessibleErrorMessageoften from aria-errormessageIf 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.");
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.
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.');
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.
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.
toMatchAriaSnapshotLocks down the structure of a region — forms, navigation, dialogs — without coupling to visual design.
toHaveAccessibleDescriptionChecks helper text or error messages tied via aria-describedby. Genuinely complementary to role-based locators.
toHaveAccessibleName / toHaveRoleFocused a11y regression tests on stable locators (test ids, positional). Fail with a clear diff when a name silently regresses.
Lock down the search page sidebars
/search/. The page exposes two labelled navs: "Collections" and
"Sort by".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)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!
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!