How to test aria attributes

Read the state the browser exposed, not the attribute you wrote. Call Accessibility.getPartialAXTree on every element carrying an aria-* attribute and compare the two lists. On the test page below, aria-checked="yes" arrived in the tree as checked="true", and a misspelled attribute produced no state at all.

Why check this

Run this after a change to a custom widget or a design-system component, before staging sign-off. An ARIA state attribute fails in silence: nothing renders differently, the console says nothing about it, and the build passes.

The failure it prevents is a filter panel whose checkboxes are written aria-checked="yes". Every one reports checked to the platform, so all filters read as selected when none are, and the state never flips.

What this procedure decides

It decides whether each attribute reached the accessibility tree, and with what value, which is what Chrome hands to every assistive technology on the machine. It does not decide whether that state is the right one: a button exposing expanded=false while its menu is open is a logic defect this reading cannot see.

Prerequisites

<!-- attrs.html -->
<!doctype html>
<html lang="en">
<head><meta charset="utf-8"><title>Notification settings</title></head>
<body>
  <h1>Notification settings</h1>
  <div id="ok-checkbox"   role="checkbox" tabindex="0" aria-checked="true">Email alerts</div>
  <div id="bad-value"     role="checkbox" tabindex="0" aria-checked="yes">SMS alerts</div>
  <div id="no-state"      role="checkbox" tabindex="0">Push alerts</div>
  <div id="slider" role="slider" tabindex="0" aria-label="Retry delay"
       aria-valuemin="0" aria-valuemax="60"></div>
  <button id="typo" aria-hasspopup="menu" aria-expanded="false">Filters</button>
  <button id="not-allowed" aria-selected="true">Save view</button>
  <button id="bad-expanded" aria-expanded="collapsed">Details</button>
  <button id="tristate" aria-pressed="mixed">Bold</button>
  <input id="upper" type="text" aria-label="Search" aria-required="TRUE">
</body>
</html>
<!-- values.html -->
<!doctype html>
<html lang="en"><head><meta charset="utf-8"><title>value probe</title></head><body>
<div id="v-true"  role="checkbox" tabindex="0" aria-checked="true">a</div>
<div id="v-false" role="checkbox" tabindex="0" aria-checked="false">b</div>
<div id="v-yes"   role="checkbox" tabindex="0" aria-checked="yes">c</div>
<div id="v-no"    role="checkbox" tabindex="0" aria-checked="no">d</div>
<div id="v-0"     role="checkbox" tabindex="0" aria-checked="0">e</div>
<div id="v-1"     role="checkbox" tabindex="0" aria-checked="1">f</div>
<div id="v-FALSE" role="checkbox" tabindex="0" aria-checked="FALSE">g</div>
<div id="v-empty" role="checkbox" tabindex="0" aria-checked="">h</div>
<div id="v-mixed" role="checkbox" tabindex="0" aria-checked="mixed">i</div>
</body></html>
// aria-attrs.mjs
import { launch } from 'puppeteer-core';
const CHROME = process.env.CHROME ?? 'C:/Program Files/Google/Chrome/Application/chrome.exe';
const browser = await launch({ executablePath: CHROME, headless: true });
const page = await browser.newPage();
const cdp = await page.createCDPSession();
await page.goto(process.argv[2], { waitUntil: 'networkidle2' });
const { root } = await cdp.send('DOM.getDocument', { depth: -1 });
const { nodeIds } = await cdp.send('DOM.querySelectorAll', { nodeId: root.nodeId, selector: '[id]' });
for (const nodeId of nodeIds) {
  const { node } = await cdp.send('DOM.describeNode', { nodeId });
  const a = {};
  for (let i = 0; i < node.attributes.length; i += 2) a[node.attributes[i]] = node.attributes[i + 1];
  const written = Object.keys(a).filter((k) => k.startsWith('aria-') && k !== 'aria-label');
  const [n] = (await cdp.send('Accessibility.getPartialAXTree', { nodeId, fetchRelatives: false })).nodes;
  const props = (n.properties ?? [])
    .filter((p) => !['focusable', 'invalid', 'settable', 'editable', 'multiline', 'readonly'].includes(p.name))
    .map((p) => `${p.name}=${JSON.stringify(p.value.value)}`);
  if (n.value) props.unshift(`value=${JSON.stringify(n.value.value)}`);
  console.log('#' + a.id);
  console.log('  written  ' + (written.map((k) => `${k}="${a[k]}"`).join(' ') || '(no state attribute)'));
  console.log('  exposed  role=' + (n.role?.value ?? '(none)') + '  ' + (props.join(' ') || '(no state)'));
}
await browser.close();
// aria-diff.mjs
import { launch } from 'puppeteer-core';

// The 48 states and properties defined by WAI-ARIA 1.2. Anything else is a typo.
const ARIA12 = new Set(('activedescendant atomic autocomplete busy checked colcount colindex colspan ' +
  'controls current describedby details disabled dropeffect errormessage expanded flowto grabbed ' +
  'haspopup hidden invalid keyshortcuts label labelledby level live modal multiline multiselectable ' +
  'orientation owns placeholder posinset pressed readonly relevant required roledescription rowcount ' +
  'rowindex rowspan selected setsize sort valuemax valuemin valuenow valuetext').split(' ').map((s) => 'aria-' + s));

// Attributes whose result is not a property of the same name.
const MAPPED = { 'aria-label': 'name', 'aria-labelledby': 'name', 'aria-describedby': 'description', 'aria-valuenow': 'value' };

const CHROME = process.env.CHROME ?? 'C:/Program Files/Google/Chrome/Application/chrome.exe';
const browser = await launch({ executablePath: CHROME, headless: true });
const page = await browser.newPage();
const cdp = await page.createCDPSession();
await page.goto(process.argv[2], { waitUntil: 'networkidle2' });
const { root } = await cdp.send('DOM.getDocument', { depth: -1 });
const { nodeIds } = await cdp.send('DOM.querySelectorAll', { nodeId: root.nodeId, selector: '*' });
for (const nodeId of nodeIds) {
  const { node } = await cdp.send('DOM.describeNode', { nodeId });
  const a = {};
  for (let i = 0; i < node.attributes.length; i += 2) a[node.attributes[i]] = node.attributes[i + 1];
  const written = Object.keys(a).filter((k) => k.startsWith('aria-'));
  if (!written.length) continue;
  const [n] = (await cdp.send('Accessibility.getPartialAXTree', { nodeId, fetchRelatives: false })).nodes;
  const props = Object.fromEntries((n.properties ?? []).map((p) => [p.name, p.value.value]));
  if (n.value) props.value = n.value.value;
  if (n.name) props.name = n.name.value;
  for (const attr of written) {
    const where = `#${a.id ?? node.nodeName.toLowerCase()}`.padEnd(14) + `${attr}="${a[attr]}"`.padEnd(30);
    if (!ARIA12.has(attr)) { console.log(where + 'NOT A WAI-ARIA 1.2 ATTRIBUTE'); continue; }
    const key = MAPPED[attr] ?? attr.slice(5);
    if (!(key in props)) console.log(where + `DROPPED, role=${n.role?.value} exposes no ${key}`);
    else console.log(where + `ok, ${key}=${JSON.stringify(props[key])}`);
  }
}
await browser.close();
// required.mjs
import { launch } from 'puppeteer-core';
// Required states and properties, from the role definitions in WAI-ARIA 1.2.
const REQUIRED = { checkbox: ['aria-checked'], radio: ['aria-checked'], switch: ['aria-checked'],
  slider: ['aria-valuenow'], scrollbar: ['aria-controls', 'aria-valuenow'],
  combobox: ['aria-expanded'], heading: ['aria-level'] };
const CHROME = process.env.CHROME ?? 'C:/Program Files/Google/Chrome/Application/chrome.exe';
const browser = await launch({ executablePath: CHROME, headless: true });
const page = await browser.newPage();
const cdp = await page.createCDPSession();
await page.goto(process.argv[2], { waitUntil: 'networkidle2' });
const { root } = await cdp.send('DOM.getDocument', { depth: -1 });
const { nodeIds } = await cdp.send('DOM.querySelectorAll', { nodeId: root.nodeId, selector: '[role]' });
for (const nodeId of nodeIds) {
  const { node } = await cdp.send('DOM.describeNode', { nodeId });
  const a = {};
  for (let i = 0; i < node.attributes.length; i += 2) a[node.attributes[i]] = node.attributes[i + 1];
  const need = REQUIRED[a.role];
  if (!need) continue;
  const [n] = (await cdp.send('Accessibility.getPartialAXTree', { nodeId, fetchRelatives: false })).nodes;
  const props = Object.fromEntries((n.properties ?? []).map((p) => [p.name, p.value.value]));
  if (n.value) props.value = n.value.value;
  for (const attr of need) {
    const key = attr === 'aria-valuenow' ? 'value' : attr.slice(5);
    const shown = key in props ? `${key}=${JSON.stringify(props[key])}` : `no ${key}`;
    console.log(`#${a.id}`.padEnd(14) + `role=${a.role}`.padEnd(16) +
      `${attr} ${attr in a ? 'written' : 'MISSING'}`.padEnd(26) + `tree says ${shown}`);
  }
}
await browser.close();

Steps

  1. Step 1.

    Put what the markup says next to what the tree says.

    node aria-attrs.mjs http://127.0.0.1:8756/attrs.html
    
    #ok-checkbox
    written  aria-checked="true"
    exposed  role=checkbox  checked="true"
    #bad-value
    written  aria-checked="yes"
    exposed  role=checkbox  checked="true"
    #no-state
    written  (no state attribute)
    exposed  role=checkbox  checked="false"
    #slider
    written  aria-valuemin="0" aria-valuemax="60"
    exposed  role=slider  value=30 orientation="horizontal" valuemin=0 valuemax=60 valuetext=""
    #typo
    written  aria-hasspopup="menu" aria-expanded="false"
    exposed  role=button  expanded=false
    #not-allowed
    written  aria-selected="true"
    exposed  role=button  (no state)
    #bad-expanded
    written  aria-expanded="collapsed"
    exposed  role=button  expanded=true
    #tristate
    written  aria-pressed="mixed"
    exposed  role=button  pressed="mixed"
    #upper
    written  aria-required="TRUE"
    exposed  role=textbox  required=true

    Four lines are defects and none looks like one. #bad-value wrote yes and got checked="true". #bad-expanded wrote collapsed and got expanded=true, the opposite of the word. #typo and #not-allowed wrote an attribute that produced nothing. #no-state and #slider carry a state nobody wrote.

  2. Step 2.

    Turn that comparison into a verdict per attribute.

    node aria-diff.mjs http://127.0.0.1:8756/attrs.html
    
    #ok-checkbox  aria-checked="true"           ok, checked="true"
    #bad-value    aria-checked="yes"            ok, checked="true"
    #slider       aria-label="Retry delay"      ok, name="Retry delay"
    #slider       aria-valuemin="0"             ok, valuemin=0
    #slider       aria-valuemax="60"            ok, valuemax=60
    #typo         aria-hasspopup="menu"         NOT A WAI-ARIA 1.2 ATTRIBUTE
    #typo         aria-expanded="false"         ok, expanded=false
    #not-allowed  aria-selected="true"          DROPPED, role=button exposes no selected
    #bad-expanded aria-expanded="collapsed"     ok, expanded=true
    #tristate     aria-pressed="mixed"          ok, pressed="mixed"
    #upper        aria-label="Search"           ok, name="Search"
    #upper        aria-required="TRUE"          ok, required=true

    Two kinds of silence separate here. aria-hasspopup is not an ARIA attribute at all, so tree reading never finds it: the name list in the script is what catches it. aria-selected is a real attribute on a role that does not support it, so it reached the browser and was dropped. #bad-value and #bad-expanded are reported ok, because they did produce a state.

  3. Step 3.

    Find out which values the browser accepts, using nine of them on one attribute.

    node aria-attrs.mjs http://127.0.0.1:8756/values.html
    
    #v-true
    written  aria-checked="true"
    exposed  role=checkbox  checked="true"
    #v-false
    written  aria-checked="false"
    exposed  role=checkbox  checked="false"
    #v-yes
    written  aria-checked="yes"
    exposed  role=checkbox  checked="true"
    #v-no
    written  aria-checked="no"
    exposed  role=checkbox  checked="true"
    #v-0
    written  aria-checked="0"
    exposed  role=checkbox  checked="true"
    #v-1
    written  aria-checked="1"
    exposed  role=checkbox  checked="true"
    #v-FALSE
    written  aria-checked="FALSE"
    exposed  role=checkbox  checked="false"
    #v-empty
    written  aria-checked=""
    exposed  role=checkbox  checked="false"
    #v-mixed
    written  aria-checked="mixed"
    exposed  role=checkbox  checked="mixed"

    no reads as checked. 0 reads as checked. Only false in any case, the empty string, and mixed produce anything else. WAI-ARIA 1.2 section 9.2 requires this: an unknown value exposed as a platform boolean counts as false only when empty or absent, and "any other value as true". A wrong value is not ignored, it is on.

  4. Step 4.

    List the states each role requires, and see what the browser used when one was absent.

    node required.mjs http://127.0.0.1:8756/attrs.html
    
    #ok-checkbox  role=checkbox   aria-checked written      tree says checked="true"
    #bad-value    role=checkbox   aria-checked written      tree says checked="true"
    #no-state     role=checkbox   aria-checked MISSING      tree says checked="false"
    #slider       role=slider     aria-valuenow MISSING     tree says value=30

    Neither missing attribute is reported as missing. The checkbox reads checked="false", identical to one written that way, and the slider reads a value the page never contained. Chrome applied the fallback table from WAI-ARIA 1.2, so the gap shows only in the MISSING column, which comes from the markup.

How to read the result

| What you see | What it means | What to do | | --- | --- | --- | | written aria-checked="yes", exposed checked="true" | The value is not in the allowed set and was treated as true | Write true, false or mixed. Any other string turns the state on. | | NOT A WAI-ARIA 1.2 ATTRIBUTE | The attribute name is misspelled or invented | Fix the spelling. Nothing in the browser reports this, and the tree looks the same with or without it. | | DROPPED, role=X exposes no Y | A real attribute the element's role does not support | Move it to the element that has the role, or change the role. | | MISSING with a state still in the tree | The browser substituted a fallback value | Write the attribute. The current state is a repair, not your intent. | | exposed role=button (no state) | Nothing this element declares reached the tree | Read step 2 to find out which attribute was dropped and why. |

Thresholds

A slider with no aria-valuenow reports (aria-valuemax - aria-valuemin) / 2, which is 30 on the page above Source: https://www.w3.org/TR/wai-aria-1.2/#authorErrorDefaultValuesTable

Common mistakes

Sign: An invalid ARIA value is assumed to be ignored.Cause: Step 3 wrote aria-checked as no, and the tree returned checked=true. The rule in WAI-ARIA 1.2 section 9.2 is to treat any value that is not empty and not a known token as true. A toggle that writes yes and no instead of true and false is reported as permanently on, and its two states are indistinguishable.
Sign: A missing required attribute is expected to show up as a gap in the tree.Cause: It does not. In step 4 the checkbox with no aria-checked reads checked=false and the slider with no aria-valuenow reads 30. Both figures come from the browser, not from the page. Comparing two trees, before and after a state change, is what exposes them: the repaired value never moves.
Sign: A misspelled aria attribute is searched for in the accessibility tree.Cause: There is nothing to find. aria-hasspopup in step 2 produced no property, no console message and no DevTools issue: the Issues panel listed nothing for this page. Only a check against the list of 48 attribute names catches it, which is why that list sits in the script.
Sign: An automated assertion reads the attribute instead of the state.Cause: getAttribute and the ARIA reflection property both return the literal string, so an assertion on aria-checked being yes passes on the markup and says nothing about the computed state. The tree is the layer assistive technology reads. Assert on role and state, as step 1 prints them.

What to check next

FAQ

How do I check aria roles?

Read the role line in step 1. An unknown role name is discarded and the element falls back to its native role, so <table role="foo"> is exposed as a table. Comparing written role against computed role finds the discarded ones.

Which aria attributes matter for accessibility?

The ones the element's role requires, plus any state that changes at runtime. Step 4 lists the required set per role. An attribute that never moves while the widget does is the usual defect, and a single capture reads it as correct.

Does an invalid aria value break the page?

Nothing breaks. The page renders, the console carries no ARIA message, and the DevTools Issues panel listed nothing for either test page. The element carries a state the author did not write, which is why this check compares the two lists.

Is aria-checked case sensitive?

Not in Chrome. FALSE was exposed as checked="false" in step 3. Relying on that is still a defect: the tokens in WAI-ARIA 1.2 are lower case, and another engine may treat FALSE as unknown, which turns the state on.

Can I test aria attributes without a screen reader?

Yes, and no screen reader produced any output on this page. Every figure above is a state Chrome handed to the platform accessibility API, so an attribute missing there is missing for every assistive technology.

Verified

Verified by Maks VernyChrome 152.0.7977.76Node 22.23.2puppeteer-core 25.10.0

Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.

intermediate9 minpublished updated Maks Verny