Accessibility · Testing Accessibility
The Accessibility Tree as a Test Oracle
How browsers expose roles, names, states, and relations through the accessibility API, and how tests can assert on that projection instead of on markup.
A test that asserts button[aria-expanded="false"] exists is a test about markup. A test that asserts the tree contains a button named “Filters” whose expanded state is false is a test about what a screen reader receives. The two pass and fail independently. Markup assertions break on refactors that change nothing for users; tree assertions survive those refactors and catch the regressions that markup assertions miss, including the case where the attribute is present but the element was never exposed at all.
The projection from DOM to accessibility tree is where the two diverge, and it is worth understanding before writing any assertion against it.
Two trees, one document
The DOM is a tree of nodes with attributes, text, and style. The accessibility tree is a separate structure built from a subset of those nodes, each carrying a role, a name, a set of states, and relations to other nodes. Browsers build it lazily and incrementally, and they translate it into a platform API: IAccessibility on Windows, the AX API on macOS, and AT-SPI on Linux. Assistive technology reads the platform API, never the DOM.
Not every DOM node appears in the projection. A <span> used purely for styling contributes no node. A <div> that contains only text contributes no node of its own; its text becomes a text leaf attached to the nearest ancestor that is exposed. A <p> contributes a paragraph node. A <button> contributes a button node with a name computed from its contents, plus a child text leaf that assistive technology may or may not announce separately depending on verbosity settings.
The rules that decide exposure come from two sources. HTML defines default semantics for its elements: h1 through h6 map to heading roles with a level, ul and ol map to lists, table maps to a table with rows and cells, input type="checkbox" maps to a checkbox with a checked state. ARIA supplies explicit roles and states for elements HTML cannot express, and it also supplies the mechanism to suppress semantics, role="presentation", which removes an element’s native role so that it becomes a container.
The interaction between the two is the source of most surprises. Setting role="button" on a <div> gives it a button role, but it does not give it keyboard behaviour, focusability, or the ability to be activated by Enter and Space; those come from the element’s native semantics or from script. Conversely, a <button> with role="presentation" is a case where the suppression is itself suppressed: ARIA requires that a presentational role be ignored on an element that is focusable or that carries a global state or property, so the button keeps both its role and its focusability, and an author who expected the semantics to disappear gets no change at all. Neither outcome is visible in a markup diff.
Reading the tree in a test
Browser automation exposes the projection directly. In Playwright the accessibility snapshot returns a serialized tree whose nodes carry role, name, and role-specific properties such as checked, expanded, disabled, and level. Querying by role and name is the same operation assistive technology performs when a user navigates by element type or by name, which is why the queries make good assertions.
// Assert the projection, not the attributes that were supposed to produce it.
const findNode = (n, role, name) =>
(n.role === role && n.name === name) ? n
: (n.children || []).map(c => findNode(c, role, name)).find(Boolean) || null;
const tree = await page.accessibility.snapshot({ interestingOnly: true });
expect(findNode(tree, 'button', 'Filters').expanded).toBe(false);
await page.getByRole('button', { name: 'Filters' }).click();
const after = await page.accessibility.snapshot({ interestingOnly: true });
expect(findNode(after, 'button', 'Filters').expanded).toBe(true);
interestingOnly prunes nodes the browser considers uninteresting, which is roughly the set a screen reader would skip in browse mode: generic containers, text leaves attached to a named parent, presentational wrappers. It makes assertions readable and it also hides structure. A test that needs to verify reading order or heading nesting should take the unpruned tree, because the pruned one has already discarded the evidence.
The name in a tree query is the computed accessible name, not the attribute. A button containing an icon <svg> and the text “Close” has the name “Close” whether that string came from aria-label, from a visually hidden span, or from the text node itself. Asserting on the computed name means the test states the contract, that the control is announced as “Close”, and leaves the implementation free. The same computation is what a rule engine performs when it checks for an empty name, applied there as a pass-or-fail predicate and here as an assertion about the exact string the user receives.
Relations that markup does not state
Some of the most important facts in the tree are relations, and relations are exactly what a DOM query cannot see. aria-labelledby and aria-describedby are ID lists, but their effect in the tree is a resolved reference: the name or description is the concatenated text of the referenced elements at the time of computation. A test that reads the attribute learns which IDs were written. A test that reads the tree learns what was actually announced.
Ownership is the other relation. A listbox owns its options; a combobox owns its popup; a grid owns its rows; a tablist owns its tabs. ARIA expresses ownership through required context roles and through aria-owns, which rewrites the tree parent-child relationship so that a node can be owned by an element that is not its DOM parent. This is the mechanism that lets a popup rendered at the end of the document body still be owned by the combobox that opened it. Testing the tree means the assertion is about ownership, and aria-owns versus DOM nesting becomes an implementation detail that the test does not have to encode.
Flow-to relations, exposed through aria-flowto and through the reading order of the tree, determine the order in which a screen reader traverses content. Reading order is derived from DOM order except where aria-owns and CSS ordering intervene, and browsers have historically disagreed about the CSS cases. A test that snapshots the unpruned tree captures the order as the browser resolved it, which is the only version that matters.
States, and why they are not attributes
A state in the tree is a boolean or a value maintained by the browser, and it is not always a mirror of an attribute. A checkbox’s checked state comes from the checked property, which can differ from the checked attribute after a user interaction: the attribute reflects the initial state, the property reflects the current one. An ARIA checkbox carries aria-checked as a string that must be updated by script, and the tree exposes the parsed value. A native <details> element exposes an expanded state without any author-written attribute, driven by the open attribute that the browser toggles.
Testing states through the tree collapses these differences. The assertion is “the checkbox is checked after the click” whether the implementation used a native input, a role="checkbox" with a managed attribute, or a custom element with a shadow root. The corollary is that a state assertion reports that something is wrong without identifying which of the three mechanisms produced it, so a failure sends the investigation back to the element markup.
Disabled is the state with the most implementation variation. A native <button disabled> is removed from the tab order and its click events do not fire. An element with aria-disabled="true" keeps its place in the tab order and still receives clicks unless the script suppresses them. Both project a disabled state into the tree. A test that asserts only the state passes for both, and the difference in keyboard behaviour needs a separate assertion.
Where the projection is lossy or late
The tree is not a specification-stable artifact. Browsers differ in which generic containers they expose, how much text they split into separate leaves, and whether they expose certain attributes as properties. Chrome, Firefox, and Safari each ship their own mapping tables and their own bugs, and the platform APIs on the three operating systems do not have identical vocabularies. A snapshot taken in one engine is evidence about that engine.
Timing is the second source of error. The tree is updated asynchronously relative to DOM mutation, and a snapshot taken immediately after a script that changes aria-expanded can return the previous state. Automation frameworks that wait for network idle do not wait for the accessibility tree. The reliable pattern is to poll the snapshot until the expected state appears, with a bound, rather than to read it once after a click.
The third is that the tree is computed for a particular node in a particular state of visibility. A modal dialog that sets aria-hidden="true" on the rest of the page removes those nodes from the projection entirely; a test that snapshots while the dialog is open will not find the background content, and that absence is the correct result. An element hidden with display: none is likewise absent. An element hidden with visibility: hidden is absent from the tree but present in layout. An element moved off-screen with a negative offset is present in both. These four cases look identical in a screenshot of the markup and are four different outcomes in the tree, which is a good argument for making the tree, not the CSS, the thing under test. The same projection is what a widget contract test asserts against when it checks that a popup is owned by the combobox that opened it.