ARIA Snapshots
ARIA snapshots let you test the accessibility structure of your pages. Instead of asserting against raw HTML or visual output, you assert against the accessibility tree — the same structure that screen readers and other assistive technologies use.
Given this HTML:
<body>
<h1>Welcome</h1>
<nav>
<a href="/">Home</a>
<a href="/about">About</a>
</nav>
</body>You can assert its accessibility tree:
await expect.element(page.getByRole('main')).toMatchAriaInlineSnapshot(`
- heading "Welcome" [level=1]
- navigation:
- link "Home":
- /url: /
- link "About":
- /url: /about
`)This catches accessibility regressions: missing labels, broken roles, incorrect heading levels, and more — things that DOM snapshots would miss.
Snapshot Workflow
ARIA snapshots use the same Vitest snapshot workflow as other snapshot assertions. File snapshots, inline snapshots, --update / -u, watch mode updates, and CI snapshot behavior all work the same way.
See the main Snapshot guide for the general snapshot workflow, update behavior, and review guidelines.
Basic Usage
Given a page with this HTML:
<body>
<h1>Log In</h1>
<input aria-label="Email" />
<input aria-label="Password" type="password" />
<button>Submit</button>
</body>File Snapshots
Use toMatchAriaSnapshot() to store the snapshot in a .snap file alongside your test:
import { expect, test } from 'vitest'
test('login form', async () => {
await expect.element(page.getByRole('main')).toMatchAriaSnapshot()
})On first run, Vitest generates a snapshot file entry:
- heading "Log In" [level=1]
- textbox "Email"
- textbox "Password"
- button "Submit"Inline Snapshots
Use toMatchAriaInlineSnapshot() to store the snapshot directly in the test file:
import { expect, test } from 'vitest'
test('login form', async () => {
await expect.element(page.getByRole('main')).toMatchAriaInlineSnapshot(`
- heading "Log In" [level=1]
- textbox "Email"
- textbox "Password"
- button "Submit"
`)
})Browser Mode Retry Behavior
In Browser Mode, expect.element() automatically retries ARIA snapshot assertions until the accessibility tree matches or the timeout is reached:
await expect.element(page.getByRole('main')).toMatchAriaInlineSnapshot(`
- heading "Log In" [level=1]
- textbox "Email"
- textbox "Password"
- button "Submit"
`)The matcher re-queries the element and re-captures the accessibility tree on each attempt.
Retry only applies when comparing against an existing snapshot. On first run, or when using --update, the matcher captures once and writes immediately.
Preserving Hand-Edited Patterns
When you hand-edit a snapshot to use regex patterns, those patterns survive --update. Only the literal parts that changed are overwritten. This lets you write flexible assertions that don't break when content changes.
Example
Step 1. Your shopping cart page renders this HTML:
<h1>Your Cart</h1>
<ul aria-label="Cart Items">
<li>Wireless Headphones — $79.99</li>
</ul>
<button>Checkout</button>You run your test for the first time with --update. Vitest generates the snapshot:
- heading "Your Cart" [level=1]
- list "Cart Items":
- listitem: Wireless Headphones — $79.99
- button "Checkout"Step 2. The item names and prices are seeded test data that may change. You hand-edit those lines to regex patterns, but keep the stable structure as literals:
- heading "Your Cart" [level=1]
- list "Cart Items":
- listitem: /.+ — \$\d+\.\d+/
- button "Checkout"Step 3. Later, a developer renames the button from "Checkout" to "Place Order". Running --update updates that literal but preserves your regex patterns:
- heading "Your Cart" [level=1]
- list "Cart Items":
- listitem: /.+ — \$\d+\.\d+/
- button "Place Order" 👈 New snapshot updated with new stringThe regex patterns you wrote in step 2 are preserved because they still match the actual content. Only the mismatched literal "Checkout" was updated to "Place Order".
Merge limitations
The merge works by matching nodes in order. If a literal mismatch appears before a regex node, the merge may fall back to a full update and lose subsequent regex patterns. To avoid this, place regex patterns on nodes that are likely to change, and keep stable literal nodes before them.
Snapshot Format
ARIA snapshots use a YAML-like syntax. Each line represents a node in the accessibility tree.
Each accessible element in the tree is represented as a YAML node:
- role "name" [attribute=value]role: The ARIA or implicit HTML role of the element, such asheading,list,listitem, orbutton"name": The accessible name, when present. Quoted strings match exact values, and/patterns/match regular expressions[attribute=value]: Accessibility states and properties such aschecked,disabled,expanded,level,pressed, orselected
These values come from ARIA attributes and the browser's accessibility tree, including semantics inferred from native HTML elements.
Because ARIA snapshots reflect the browser's accessibility tree, content excluded from that tree, such as aria-hidden="true" or display: none, does not appear in the snapshot.
Roles and Accessible Names
For example:
<button>Submit</button>
<h1>Welcome</h1>
<a href="/">Home</a>
<input aria-label="Email" />- button "Submit"
- heading "Welcome" [level=1]
- link "Home"
- textbox "Email"The role usually comes from the element's native semantics, though it can also be defined with ARIA. The accessible name is computed from text content, associated labels, aria-label, aria-labelledby, and related naming rules.
For a closer look at how names are computed, see Accessible Names.
Some content appears in the snapshot as a text node instead of a role-based element:
<span>Hello world</span>- text: Hello worldChildren
Child elements appear nested under their parent:
<ul>
<li>First</li>
<li>Second</li>
<li>Third</li>
</ul>- list:
- listitem: First
- listitem: Second
- listitem: ThirdIf the parent has an accessible name, the snapshot includes it before the nested children:
<nav aria-label="Main">
<a href="/">Home</a>
<a href="/about">About</a>
</nav>- navigation "Main":
- link "Home"
- link "About"If an element only contains a single text child and has no other properties, the text is rendered inline:
<p>Hello world</p>- paragraph: Hello worldAttributes
ARIA states and properties appear in brackets:
| HTML | Snapshot |
|---|---|
<input type="checkbox" checked aria-label="Agree"> | - checkbox "Agree" [checked] |
<input type="checkbox" aria-checked="mixed" aria-label="Select all"> | - checkbox "Select all" [checked=mixed] |
<button aria-disabled="true">Submit</button> | - button "Submit" [disabled] |
<button aria-expanded="true">Menu</button> | - button "Menu" [expanded] |
<h2>Title</h2> | - heading "Title" [level=2] |
<button aria-pressed="true">Bold</button> | - button "Bold" [pressed] |
<button aria-pressed="mixed">Bold</button> | - button "Bold" [pressed=mixed] |
<option selected>English</option> | - option "English" [selected] |
Attributes only appear when they are active. A button that is not disabled simply has no [disabled] attribute — there is no [disabled=false].
Pseudo-Attributes
Some DOM properties that aren't part of ARIA but are useful for testing are exposed with a / prefix:
/url:
Links include their URL:
<a href="/">Home</a>- link "Home":
- /url: //placeholder:
Textboxes can include their placeholder text:
<input aria-label="Email" placeholder="user@example.com" />- textbox "Email":
- /placeholder: user@example.comWhen does /placeholder: appear?
The /placeholder: pseudo-attribute only appears when the placeholder text is different from the accessible name. When an input has a placeholder but no aria-label or associated <label>, the browser uses the placeholder as the accessible name. In that case, the placeholder information is already in the name and is not duplicated.
- When placeholder is the accessible name:
<input placeholder="Search" />- textbox "Search"- When placeholder differs from the accessible name:
<input placeholder="Search" aria-label="Search products" />- textbox "Search products":
- /placeholder: SearchMatching
Partial Matching
Templates match partially by default — you don't need to list every node. Only the nodes you include are checked:
<body>
<h1>Welcome</h1>
<p>Some intro text</p>
<button>Get Started</button>
</body>// This passes even if the page has other elements
await expect.element(page.getByRole('main')).toMatchAriaInlineSnapshot(`
- heading "Welcome" [level=1]
`)Regular Expressions
Use regex patterns to match names flexibly:
<h1>Welcome, Alice</h1>
<a href="https://example.com/profile/123">Profile</a>- heading /Welcome, .*/
- link "Profile":
- /url: /https:\/\/example\.com\/.*/Regex also works in pseudo-attribute values:
<input aria-label="Search" placeholder="Type to search..." />- textbox "Search":
- /placeholder: /Type .*/