Accessibility Testing Recipes
Permalink to "Accessibility Testing Recipes"This topic collects practical, copy-ready recipes for testing the accessibility of data interfaces automatically. Each recipe addresses one layer of testing with one tool, and each is written for the specific problems data UIs have: components whose accessibility changes with state, keyboard contracts that regress silently, tables whose header wiring breaks in refactors, and dashboards with dozens of components per page.
The recipes are referenced from every section of this site. Where a guide on sortable grids, live regions or chart alternatives says “test this”, the recipe for how lives here. It is written for frontend engineers adding tests to data products and for design-system maintainers building shared test utilities.
It belongs to testing & auditing accessible data interfaces, alongside the pipeline setup in automated accessibility testing pipelines.
WCAG criteria in scope
Permalink to "WCAG criteria in scope"Automated recipes can check parts of many criteria. This table shows which recipe covers which, and where automation stops.
| Criterion | Level | Recipe coverage |
|---|---|---|
| 1.3.1 Info and Relationships | A | axe (headers, roles), role queries (header and row structure) |
| 4.1.2 Name, Role, Value | A | axe (names exist), role queries (names are right), state assertions |
| 2.1.1 Keyboard | A | Keyboard contract tests in a real browser |
| 2.1.2 No Keyboard Trap | A | Keyboard tests: tab in, tab out |
| 2.4.3 Focus Order | A | Keyboard tests: focus after actions |
| 4.1.3 Status Messages | AA | Role queries on status text; speech needs smoke tests |
| 1.4.3 Contrast (Minimum) | AA | axe text contrast; charts and focus need screenshot tests |
| 2.4.7 / 2.4.11 Focus visible / not obscured | AA | Screenshot-based focus tests; not axe |
Prerequisites
Permalink to "Prerequisites"- A shared axe configuration and suppression ledger: configuring axe-core rules and handling false positives.
- A pipeline to run tests in: setting up axe-core in a GitHub Actions pipeline.
- Components with documented keyboard contracts and states, such as the grid in implementing roving tabindex for custom data grids.
- Seeded, deterministic test data.
ARIA & HTML spec reference
Permalink to "ARIA & HTML spec reference"The recipes do not introduce ARIA of their own; they assert the attributes the rest of this site recommends. The most-asserted attributes and the recipe that checks each:
| Attribute / role | Asserted by | Typical assertion |
|---|---|---|
role="grid" + name |
Role queries | getByRole('grid', { name: 'Open invoices' }) |
columnheader / rowheader |
Role queries, axe | Headers exist; th-has-data-cells passes |
aria-sort |
Role queries, axe | One header sorted; valid placement |
tabindex roving |
Keyboard contract | Exactly one tabindex="0" in the grid |
aria-selected, aria-expanded |
Role queries | State matches the action |
role="status" text |
Role queries, keyboard tests | Message present after the action |
aria-invalid + description |
axe, role queries | Error associated with the editor |
Step-by-step implementation
Permalink to "Step-by-step implementation"Step 1 — Role queries in every component test (SC 1.3.1, 4.1.2)
Permalink to "Step 1 — Role queries in every component test (SC 1.3.1, 4.1.2)"const grid = screen.getByRole('grid', { name: 'Open invoices' });
Step 2 — Per-state axe scans (SC 1.3.1, 4.1.2, 1.4.3)
Permalink to "Step 2 — Per-state axe scans (SC 1.3.1, 4.1.2, 1.4.3)"await test.step('sorted', async () => { /* act, settle */ expect(await scan(page, testInfo, 'sorted')).toEqual([]); });
Step 3 — Keyboard contracts as data (SC 2.1.1, 2.1.2, 2.4.3)
Permalink to "Step 3 — Keyboard contracts as data (SC 2.1.1, 2.1.2, 2.4.3)"for (const { from, key, to } of CONTRACT) { /* focus, press, toBeFocused */ }
Step 4 — Storybook stories per state (design systems)
Permalink to "Step 4 — Storybook stories per state (design systems)"export const SortedByAmount = { play: async ({ canvasElement }) => { /* Enter on header */ } };
Step 5 — Page-level budget (regression net)
Permalink to "Step 5 — Page-level budget (regression net)"assertions: { 'td-headers-attr': 'error', 'categories:accessibility': ['error', { minScore: 0.95 }] }
Keyboard interaction contract
Permalink to "Keyboard interaction contract"The recipes verify keyboard contracts defined elsewhere on the site. A typical grid contract, as used in the keyboard recipe:
| Key | Context | Action | Asserted by | Failure indicator |
|---|---|---|---|---|
Tab |
Before grid | Enter grid at the active cell | toBeFocused |
Focus lands elsewhere |
| Arrow keys | Grid | Move one cell | toBeFocused, tabindex="0" |
Focus moved but tabindex did not |
Home / End |
Grid | Row start / end | toBeFocused |
No movement |
Ctrl+Home |
Grid | First cell | toBeFocused |
— |
Enter |
Editable cell | Open editor | Textbox toHaveFocus |
Editor not focused |
Escape |
Editor | Cancel, return focus | Cell toBeFocused, text unchanged |
Value committed |
Tab |
Grid | Leave grid | Next control focused | Trapped |
Screen reader compatibility matrix
Permalink to "Screen reader compatibility matrix"Recipes on this page do not run screen readers. The matrix shows what each can and cannot tell you about screen reader experience.
| Recipe | Knows the name and role | Knows the announcement | Knows reader quirks |
|---|---|---|---|
| Role queries | ✓ computed via accname | Status text only | ✗ |
| axe scans | ✓ exists and valid | ✗ | ✗ |
| Keyboard contracts | ✗ | ✗ | ✗ |
| Storybook checks | ✓ | ✗ | ✗ |
| Screen reader smoke tests (other topic) | ✓ | ✓ | ✓ for the readers automated |
Edge cases & failure modes
Permalink to "Edge cases & failure modes"1. Tests that pass because the component did not render
Permalink to "1. Tests that pass because the component did not render"Diagnosis: a failed data fetch renders an empty page; axe finds nothing wrong and Lighthouse scores higher. Fix: assert content presence (row count) before every scan.
2. Scans mid-transition
Permalink to "2. Scans mid-transition"Diagnosis: flaky contrast or structure violations that disappear on re-run. Fix: wait for a settled signal; disable animations in test config.
3. Rewriting role queries as test ids
Permalink to "3. Rewriting role queries as test ids"Diagnosis: a refactor removes a name; the test is “fixed” with getByTestId. Fix: code review rule — changes from role to test id queries need justification.
4. Global rule disabling
Permalink to "4. Global rule disabling"Diagnosis: a shared config turns off color-contrast to fix three charts. Fix: suppression ledger with selectors, reasons and expiry.
5. Keyboard tests that click
Permalink to "5. Keyboard tests that click"Diagnosis: setup clicks mask missing tabindex. Fix: one programmatic focus, then keys only.
Per-state axe scans with Playwright
Permalink to "Per-state axe scans with Playwright"axe scans of grid states with Playwright drives a grid through loaded, sorted, empty, editing, error and dialog states and scans each, with a reusable helper and settled-signal waits.
expect(await scan(page, testInfo, 'editing')).toEqual([]);
Behaviour note: the settled signals double as cheap behavioural assertions.
Keyboard contracts with Playwright
Permalink to "Keyboard contracts with Playwright"Testing grid keyboard navigation with Playwright turns a grid’s keyboard contract into a data table of start cell, key and expected cell, asserting focus and roving tabindex.
await expect(cell(grid, ...to)).toHaveAttribute('tabindex', '0');
Behaviour note: keyboard only after the first focus — clicks hide the bugs.
cypress-axe for data tables
Permalink to "cypress-axe for data tables"cypress-axe for data tables adds scoped scans and state commands to a Cypress suite, with violation logging that makes CI failures readable.
cy.checkA11y('#invoice-table', AXE_OPTIONS, logViolations);
Behaviour note: inject axe after every visit.
Storybook accessibility checks
Permalink to "Storybook accessibility checks"Storybook accessibility checks for data components writes a story per state, reaches interactive states with play functions, and runs every story through axe in CI.
export const Empty = { args: { rows: [] } };
Behaviour note: the addon can only check what stories render.
Testing Library role queries
Permalink to "Testing Library role queries"Testing Library role queries for grids and tables queries tables the way assistive technology finds things, so broken roles, names and headers fail ordinary unit tests.
within(grid).getByRole('rowheader', { name: 'INV-1042' });
Behaviour note: keep fixtures small; role queries are expensive in jsdom.
Lighthouse CI budgets
Permalink to "Lighthouse CI budgets"Lighthouse CI accessibility budgets configures per-audit gates and a ratcheting score floor as a page-level regression net.
'button-name': 'error'
Behaviour note: a score of 100 is not WCAG conformance.
Cross-cutting concerns
Permalink to "Cross-cutting concerns"One configuration everywhere. axe runs inside jest-axe, Playwright, Cypress, Storybook, pa11y and Lighthouse. If each has its own rule set, results disagree and suppressions multiply. Share one options object and one suppression ledger across all of them.
Deterministic data. Accessibility tests break for the same reasons other UI tests do: random data, time-dependent content, network variance. Seed data, freeze time where content depends on it, and wait on conditions rather than timeouts.
Tests as documentation. A keyboard contract table and a story-per-state list are the most accurate documentation of a component’s accessibility behaviour. Publish them with the component.
The limits of automation. Every recipe here checks structure or keyboard focus. None checks whether announcements are timely and understandable, whether reading order makes sense, or whether a chart’s summary says the right thing. That is the job of screen reader smoke testing and manual accessibility audits.
Ownership. Tests that fail on accessibility need an owner who can fix them. Route failures to the team that owns the component, with the rule, the state and the selector in the message; a failure that says only “accessibility violations detected” is ignored.
Where to start with an existing product
Permalink to "Where to start with an existing product"Most teams arrive here with a product that has some end-to-end tests, few accessibility checks, and more components than time. An order that pays off quickly:
- Switch component tests to role queries. Replace
getByTestIdand class selectors in existing tests withgetByRoleand names, one component at a time. Every conversion is a small accessibility test, and the failures you hit are real bugs. - Add a scoped axe scan to the most-used data component in its three most common states — loaded, sorted, empty. Fix what it finds.
- Write the keyboard contract for that component as a data table and test it. Keyboard regressions are the most damaging and least visible.
- Share the configuration. Move the axe options and a suppression ledger into a shared module before a second team copies the first team’s setup with its own changes.
- Extend to the next component and to more states, guided by where users and audits report problems.
- Add a page-level budget once the component tests exist, as a safety net rather than the main check.
Each step leaves the product measurably better and the suite a little larger; none requires stopping feature work.
Who these recipes serve
Permalink to "Who these recipes serve"The immediate audience is engineers, but the beneficiaries are the users whose experience the tests protect. Role queries and axe scans protect screen reader users from unnamed controls and broken tables. Keyboard contracts protect keyboard-only and switch users from traps and lost focus. Focus and contrast checks protect low-vision users. Tests are how the accessibility work done in design and implementation survives the next refactor — without them, a product’s accessibility degrades a little with every release until an audit resets it.
Design system integration
Permalink to "Design system integration"| Shared utility | What it provides | Used by |
|---|---|---|
AXE_OPTIONS + suppression ledger |
One rule set and one exception process | Every scanning recipe |
scan(page, testInfo, state) |
Scoped, logged, suppression-aware Playwright scans | Per-state scans |
| Cypress state commands + logger | Same for Cypress suites | cypress-axe recipe |
| Keyboard contract exports | Per-component contract tables | Keyboard recipe, product tests |
| Story-per-state template | Required stories for data components | Storybook recipe |
lighthouserc preset |
Data-UI audit gates | Lighthouse recipe |
Testing checklist
Permalink to "Testing checklist"Automated
Permalink to "Automated"Process
Permalink to "Process"FAQ
Permalink to "FAQ"What should an automated accessibility test suite for a data UI include?
Role-based queries in unit tests, axe scans of each component state, keyboard contract tests in a real browser, per-story checks for design-system components and a page-level regression budget — plus screen reader smoke tests and periodic manual audits, which automation cannot replace.
Why scan components in several states?
Because much of a data component’s markup — sort state, editors, errors, empty states, dialogs — only exists after interaction. A scan after load cannot see it.
Can automated tests confirm that screen readers announce things correctly?
Not these recipes. They can check that a status message exists in the DOM; checking what a screen reader actually says needs virtual or real screen reader smoke tests.
Which recipe should a team adopt first?
Role queries in existing component tests. They need no new tools, run in milliseconds, and immediately turn missing names, roles and headers into failing tests. Add per-state axe scans and keyboard contracts for the most-used data component next.
How long do these recipes add to a CI run?
Role queries add almost nothing. Per-state axe scans add a second or two per state, and keyboard contract rows a fraction of a second each. Storybook runners and Lighthouse take minutes, which is why they usually run nightly rather than per pull request.
Should accessibility tests block merges?
Structural and keyboard tests for components should, because their failures are real regressions. Page-level budgets can start as warnings while a baseline is established, then become blocking once the known issues are fixed.
How do I stop different tools reporting different accessibility results?
Use one axe configuration and one suppression ledger across jest-axe, Playwright, Cypress, Storybook and Lighthouse, so every tool applies the same rules and exceptions.
Related
Permalink to "Related"- Automated testing pipelines — where recipes run
- Screen reader smoke testing — the announcements recipes cannot see
- Manual audits — what automation cannot replace
- Accessible data tables & grid systems — the components under test
- Core ARIA & keyboard navigation — the contracts under test