Asserting Accessibility Tree Names with Playwright Snapshots

An accessible name is computed, not written. It is the result of a precedence walk across aria-labelledby, aria-label, native labelling elements, subtree text content and title, and any refactor that touches the markup can quietly move a name off that path without changing a single pixel. This guide is part of Screen Reader Automation Testing, and it covers how to author name and role assertions in the first place: pruning locator.ariaSnapshot() to the subtree under test, writing an expectation that tolerates volatile text without tolerating structural drift, and pinning the computed name of individual controls so a label that leaves the accessibility tree fails immediately under WCAG 2.2 SC 4.1.2 (Name, Role, Value).

Keep the distinction with the neighbouring technique clear. Snapshot testing accessibility trees to prevent regressions uses a committed tree as a regression lock after a violation has been fixed — the snapshot’s job there is to stop a repaired defect from coming back. This page is about the earlier step: deciding what the tree should contain, writing that expectation deliberately, and asserting names as first-class values rather than as a by-product of a diff.

Root Cause

The default move is to snapshot the page. On any real application that produces a tree of two hundred to four hundred nodes containing the header, the navigation, a cookie notice, a support widget and the footer, and every one of those regions belongs to a different team. A copy change in the promotional banner rewrites the snapshot file for a pull request that touched the checkout form, so the diff arrives with two hundred lines of noise around the three that matter. Reviewers learn to regenerate rather than read, and the assertion stops being an assertion.

Unpruned snapshots are also unstable across browser versions in a way that scoped ones are not. Playwright’s aria snapshot is built from the browser’s own accessibility tree, and that tree is filtered by an “interesting node” heuristic: generic containers with no role and no name are collapsed away, some presentational nodes are dropped, and the rules for both change between Chromium releases. A <div> wrapper that was invisible to the tree in one revision can appear as a generic node in the next, indenting everything below it. When the snapshot covers one component with eleven nodes, that upgrade produces one reviewable diff. When it covers the whole page, it produces a wall of re-indentation that nobody can distinguish from a real regression.

The third failure is volatile text. Accessible names absorb subtree content, so a name can contain a result count, a relative timestamp, a currency total or a generated id — “3 items in cart”, “updated 2 minutes ago”, “Row f39c2b”. A string comparison against a committed file fails on all of those every run, and the usual reaction is to delete the snapshot rather than to make it tolerant. What is actually needed is an expectation that pins the structure and the stable part of every name while explicitly allowing the volatile part to vary, which is exactly what a pattern in the expected value provides.

Page-scoped versus component-scoped snapshot The page snapshot contains header, navigation, cookie notice, checkout form and footer nodes totalling 312, with four sources of unrelated churn. The scoped snapshot contains only the checkout form's 11 nodes and has one source of churn, the component itself. page.accessibility root — 312 nodes banner + navigation (94 nodes) cookie notice (17 nodes) checkout form (11 nodes) support widget iframe (68 nodes) footer + legal (122 nodes) 4 unrelated owners can rewrite this file a Chromium upgrade re-indents every branch locator scope — 11 nodes form "Payment details" textbox "Card number" textbox "Expiry (MM/YY)" textbox "Security code" checkbox "Save this card" button "Pay 48.20 GBP" plus 5 nodes for the error summary state 1 owner, 1 reason to change every diff line is readable as a sentence Scoping is not an optimisation; it is what makes the file a contract instead of a log.
Only the teal branch belongs to the component under test, so anything captured outside it is future noise rather than future coverage.

Configuration

Two assertions do the work, and they fail in usefully different ways. toMatchAriaSnapshot compares a pruned subtree against a committed file and catches structural drift — a control that vanished, a heading level that moved, a list that stopped being a list. toHaveAccessibleName and toHaveRole compare one control against a literal expected value and catch the specific case this page exists for: a label that left the accessibility tree while staying on screen.

Scope the snapshot with a locator, never with the page. Use the component’s own root — a landmark role, a data-testid on the wrapper, or the form itself — and wait for the component’s readiness signal before capturing, because a tree taken mid-render contains skeleton nodes that will not be there next time.

// tests/reader/payment-form.aria.spec.ts
import { test, expect } from '@playwright/test';

test.describe('payment form accessibility contract (SC 4.1.2)', () => {
  test.beforeEach(async ({ page }) => {
    await page.goto('/checkout/payment');
    // The form sets data-ready once its controls are registered; capturing
    // before that yields skeleton nodes with no names.
    await page.locator('form[data-ready="true"]').waitFor();
  });

  test('subtree structure matches the committed contract', async ({ page }) => {
    const form = page.getByRole('form', { name: 'Payment details' });
    // External snapshot file: reviewable in the diff, updated only with
    // --update-snapshots and only in a commit that explains why.
    await expect(form).toMatchAriaSnapshot({ name: 'payment-form.aria.yml' });
  });

  test('computed names of the high-risk controls are pinned', async ({ page }) => {
    const form = page.getByRole('form', { name: 'Payment details' });

    // Name comes from <label for>. A styled <span> replacement kills it.
    await expect(form.getByTestId('card-number')).toHaveRole('textbox');
    await expect(form.getByTestId('card-number')).toHaveAccessibleName('Card number');

    // Name comes from aria-labelledby pointing at the help text id.
    await expect(form.getByTestId('security-code')).toHaveAccessibleName(
      'Security code',
    );
    await expect(form.getByTestId('security-code')).toHaveAccessibleDescription(
      '3 digits on the back of the card',
    );

    // Icon-only control: nothing visible regresses when the name disappears.
    await expect(form.getByTestId('why-cvc')).toHaveAccessibleName(
      'Why we ask for the security code',
    );
  });
});

The committed snapshot file is the interesting artifact. It is YAML, one line per interesting node, indented by tree depth, and it accepts regular expressions where a value is volatile. That last property is what makes the file survivable: the total on the pay button changes with every basket, but its shape does not, so the expectation pins the shape and lets the digits vary.

# tests/reader/__snapshots__/payment-form.aria.yml
- form "Payment details":
  - textbox "Card number"
  - textbox "Expiry (MM/YY)"
  - textbox "Security code"
  - button "Why we ask for the security code"
  - checkbox "Save this card"
  # The amount is data-dependent; the label wording is not.
  - button /^Pay \d+\.\d{2} GBP$/
  # Rendered only after a failed submit, so this branch is captured in a
  # second snapshot file rather than made optional here.

Three rules keep these files stable. Pin the browser revision in the lockfile so a tree shape change is always a deliberate upgrade. Pin locale and timeZoneId in the Playwright configuration, because both leak into names through formatted numbers and dates. And keep one file per component state rather than trying to express optional branches — an error-state tree and an idle-state tree are different contracts, and merging them produces an expectation that matches neither precisely.

Choosing which controls get a literal assertion is a risk exercise, not a coverage exercise. Three categories earn one every time. Icon-only controls come first, because their name is invisible and its loss changes nothing on screen. Controls whose name arrives from another component are second — a field labelled by a shared FormRow wrapper loses its name whenever that wrapper’s props change, and the failing pull request is in a different directory from the broken markup. Third are controls whose name comes from a translation catalogue, where a missing key yields either an empty string or a raw identifier such as checkout.pay.cta, both of which pass a snapshot regex that was written loosely. Everything else can rely on the structural file, and a suite of a dozen well-chosen literals ages better than a hundred generated ones.

Where a refactor knocks a name off the precedence ladder Five precedence steps are listed in order: aria-labelledby, aria-label, native label or legend or caption, subtree text content, and title. Each row names a common refactor that breaks it, and all five failures converge on the same result, an empty computed name that looks identical on screen. Precedence step Refactor that breaks it 1. aria-labelledby highest priority, id reference id becomes hashed by a CSS-modules upgrade 2. aria-label literal string on the element prop spread drops it in a wrapper component 3. label / legend / caption native host-language labelling <label for> restyled into a plain <span> 4. subtree text content for roles that support it text moved into a CSS pseudo-element 5. title attribute last resort, tooltip only tooltip library replaces it with a div name = "" Every one of these five refactors is invisible in a screenshot and total for anyone using the name to identify the control.
The ladder is why a name assertion is worth writing by hand: five unrelated code changes produce the same silent failure, and only the computed value reveals it.

Validation

Prove the assertion bites before trusting it. The cheapest realistic break is the third rung of the ladder: replace the <label for="card-number"> with a styled <span> that looks identical, leave everything else alone, and run the spec. The structural snapshot reports a lost name on the textbox and the explicit assertion reports the empty computed value.

# Baseline: both specs green against the committed contract.
npx playwright test tests/reader/payment-form.aria.spec.ts --reporter=list
#   ✓ subtree structure matches the committed contract (612ms)
#   ✓ computed names of the high-risk controls are pinned (388ms)

# After swapping <label for> for a styled <span>:
npx playwright test tests/reader/payment-form.aria.spec.ts --reporter=list
#   ✘ subtree structure matches the committed contract
#     - - textbox "Card number"
#     + - textbox
#   ✘ computed names of the high-risk controls are pinned
#     Expected string: "Card number"
#     Received string: ""
#     Locator: getByTestId('card-number')
#   2 failed, exit code 1

The second failure message is the one worth optimising for. Expected "Card number", Received "" names the defect without interpretation, which is why the explicit assertions exist alongside the snapshot rather than instead of it. When a change is intentional — the label copy really did become “Card number (no spaces)” — regenerate the file deliberately and update the literal assertion in the same commit, so the two never disagree:

# Intentional copy change: regenerate, then read the diff before committing.
npx playwright test tests/reader/payment-form.aria.spec.ts --update-snapshots
git diff tests/reader/__snapshots__/payment-form.aria.yml
# Update the matching toHaveAccessibleName literal in the same commit,
# otherwise the snapshot and the assertion drift apart silently.
What each assertion locks and how it fails Three rows compare toMatchAriaSnapshot, toHaveAccessibleName and toHaveRole across three columns: the scope each one locks, the failure message it produces, and whether an update flag can regenerate it without review. Assertion Locks Failure text Regenerable toMatchAriaSnapshot committed YAML file whole subtree shape unified diff yes — risk toHaveAccessibleName literal in the spec one computed name expected vs got no toHaveRole literal in the spec one resolved role expected vs got no The regenerable column is the reason to use both: an update flag cannot quietly rewrite a literal in a spec file.
Use the snapshot for breadth and the literals for the controls that must never lose their identity, because only one of the two survives a hurried update run.

Edge Cases and Conditional Guards

  • Shadow roots and slotted content. An aria snapshot follows the flattened tree, so slotted light-DOM text appears where the slot renders rather than where it is authored. Scope the locator to the custom element’s host and accept that moving a slot changes the file; if the component’s internals are private, snapshot the host’s projected result rather than reaching inside it.
  • Virtualised rows. A list that renders twelve of ten thousand rows produces a tree that depends on scroll position and row height, so the file will differ between a 1280×720 and a 1280×800 viewport. Pin the viewport in the project configuration and snapshot the row component in the harness instead, keeping the list-level assertion to the container’s role and name.
  • aria-busy and skeleton states. While aria-busy="true" is set, the subtree may contain placeholder nodes with no names, and a snapshot taken then bakes them in. Gate every capture on the component’s readiness flag, and if the component has no such flag, assert aria-busy is false before capturing rather than adding a fixed wait.

Pipeline Impact

Both assertion types are ordinary Playwright expectations, so a failure exits non-zero and the job fails — no extra reporter or exit-code plumbing is needed beyond what the scan harness in integrating axe-core Playwright into an existing project already provides. Run them as a named Playwright project so the report groups them separately from the rule scan; a naming regression and an axe violation need different fixes and different reviewers.

Commit the __snapshots__ directory so the contract travels with the code, and add a CODEOWNERS entry for it if the component belongs to a design-system team — that turns every snapshot update into a review by the people who own the contract. Never pass --update-snapshots in CI. A pipeline that regenerates its own expectations converts every regression into a new baseline, which is the single fastest way to make a tree contract worthless. Upload the actual received YAML as an artifact on failure so a reviewer can read the drift without a local reproduction, and keep the total runtime visible: a scoped snapshot plus a handful of literal assertions costs well under a second per component, which is what earns it a place on a merge gate rather than a nightly schedule.

Common Pitfalls

  • Capturing page instead of a locator, which produces a file so large that reviewers approve regenerated versions without reading them.
  • Writing the snapshot from currently-broken markup, which promotes the defect to the expected value; read the first generated file line by line before committing it.
  • Using literal names that contain data — a total, a count, a date — instead of a regex pattern, so the expectation fails on every run and gets deleted.
  • Relying on toMatchAriaSnapshot alone for the controls that matter most, since a hurried --update-snapshots erases exactly the assertion you cared about.
  • Letting the locale and timezone float, so formatted currency and dates inside accessible names differ between a developer’s machine and the runner.
  • Treating the snapshot as what a reader speaks: it is the browser’s exposed tree, with none of the reader’s verbosity, punctuation or mode behaviour applied on top.

FAQ

Why prefer locator.ariaSnapshot() over page.accessibility.snapshot() for this? The YAML output is designed to be read by humans in a diff: one node per line, roles and names as text, indentation for depth. The older JSON form nests objects and needs a normalisation pass before it is comparable, which adds code that itself needs testing. The JSON form is still useful when a test needs a specific field programmatically — for example to assert a checked or expanded value that the YAML form does not surface — so a suite occasionally uses both.

How much of a name should the regex pattern absorb? As little as possible. /^Pay \d+\.\d{2} GBP$/ pins the verb, the currency and the number format while allowing the amount, and it still fails if the button becomes “Continue” or drops the currency. A pattern like /Pay.*/ passes for almost anything and quietly stops testing. If a whole name is genuinely data-dependent, that is usually a sign the control needs a stable aria-label rather than a looser expectation.

Do these assertions replace the axe-core scan? No, and they check a different thing. axe applies a catalogue of rules to find known-bad patterns anywhere on the page; these assertions state what one component’s tree must contain and fail when it changes. A control can satisfy every axe rule and still have had its name silently rewritten from “Remove item” to “Remove”, which axe has no opinion about and a name assertion catches immediately. Both run in the same job, on the same page load, for a few hundred milliseconds each.