Writing jest-axe Tests for Data Grid Components
Permalink to "Writing jest-axe Tests for Data Grid Components"A jest-axe test renders a data-grid component in jsdom and asserts that its serialized accessibility tree contains no machine-detectable WCAG violations — missing names, invalid roles, broken ARIA references. The single failure it prevents is the structural regression: a refactor that drops aria-sort off a header or breaks the aria-labelledby link between a cell and its column, caught in milliseconds on every commit rather than in a manual audit weeks later. This is the fast unit layer of the automated accessibility testing pipeline; it runs before, and complements, the slower browser scan.
Spec reference
Permalink to "Spec reference"jest-axe is a thin adapter that runs axe-core over a DOM node and exposes a Jest matcher.
| Piece | Behaviour | Default |
|---|---|---|
toHaveNoViolations() |
Fails the assertion if the axe results contain any violation, printing each with its rule id and help text | Added to expect via expect.extend |
axe(container[, options]) |
Runs axe-core against a DOM node and resolves to a results object | Runs all enabled rules that are meaningful in jsdom |
| jsdom environment | Parses HTML and builds a DOM and accessibility tree with no layout or paint | No computed geometry, no colour, no real focus |
Default behaviour that shapes everything below: because jsdom has no layout engine, axe-core automatically disables layout-dependent rules such as color-contrast. jest-axe therefore covers the structural criteria — 1.1.1 Non-text Content (Level A), 1.3.1 Info and Relationships (Level A), and 4.1.2 Name, Role, Value (Level A) — and cannot cover 1.4.3 Contrast (Level AA), 2.1.1 Keyboard (Level A), or 2.4.3 Focus Order (Level A).
When to use vs. when NOT to
Permalink to "When to use vs. when NOT to"Use jest-axe when you want fast, per-component feedback that a grid’s static ARIA is intact: header roles present, aria-sort valid, every aria-labelledby/aria-describedby reference resolving to a real id, no invalid role. It is the cheapest place to catch these and belongs on every grid component in the suite.
Do NOT use jest-axe to check colour contrast (meaningless in jsdom), focus order, keyboard operability, or whether a live region announced. Those are dynamic and need a real browser — the GitHub Actions Playwright scan covers contrast and computed roles, and manual testing covers focus and announcement quality. The misuse to avoid is treating a green jest-axe run as full component accessibility: it certifies the static tree only.
Do NOT assert role="grid" reflexively. If your component is a native <table> with aria-sort on the headers — the pattern most sortable and filterable data grids should use — test for table and columnheader roles, not grid. Reserve role="grid" assertions for components that genuinely implement arrow-key composite navigation.
Annotated code example
Permalink to "Annotated code example"Start with the baseline: extend expect, render, and assert no violations.
// DataGrid.a11y.test.jsx
import { render } from '@testing-library/react';
import { axe, toHaveNoViolations } from 'jest-axe';
import { DataGrid } from './DataGrid';
expect.extend(toHaveNoViolations);
const rows = [
{ id: 1, name: 'Ada', status: 'Active' },
{ id: 2, name: 'Grace', status: 'Pending' },
];
test('DataGrid renders with no axe violations', async () => {
const { container } = render(<DataGrid rows={rows} />);
// axe checks SC 1.3.1 (Info & Relationships) and 4.1.2 (Name, Role, Value)
// on the serialized tree — header association, roles, ARIA ref integrity.
const results = await axe(container);
expect(results).toHaveNoViolations();
});
A bare toHaveNoViolations is necessary but not sufficient — it does not assert the grid’s specific contract. Add role-based assertions so a refactor that removes aria-sort or mislabels a header fails loudly.
import { render, screen } from '@testing-library/react';
test('column headers expose sort state', () => {
render(<DataGrid rows={rows} />);
// The header is a columnheader — SC 1.3.1 structural role.
const nameHeader = screen.getByRole('columnheader', { name: /name/i });
// aria-sort must start at 'none' on a sortable-but-unsorted column,
// so AT can distinguish "sortable" from "not sortable" — SC 4.1.2.
expect(nameHeader).toHaveAttribute('aria-sort', 'none');
// The trigger is a button inside the header, not the th itself — SC 4.1.2.
const sortButton = screen.getByRole('button', { name: /sort by name/i });
expect(sortButton).toBeInTheDocument();
});
Now the part most suites miss: re-run axe after an interaction. A grid’s tree changes when the user sorts, and a stale aria-sort is a real regression that a load-time-only scan never sees.
import userEvent from '@testing-library/user-event';
test('sorting updates aria-sort and stays violation-free', async () => {
const user = userEvent.setup();
const { container } = render(<DataGrid rows={rows} />);
await user.click(screen.getByRole('button', { name: /sort by name/i }));
// After activation, aria-sort must reflect the new direction — SC 4.1.2.
const nameHeader = screen.getByRole('columnheader', { name: /name/i });
expect(nameHeader).toHaveAttribute('aria-sort', 'ascending');
// Re-run axe on the POST-interaction tree, not just the initial render.
const results = await axe(container);
expect(results).toHaveNoViolations();
});
For invariants axe does not encode — “every rendered row has an accessible name”, “exactly one column is sorted at a time” — a small custom matcher keeps intent readable across the suite.
// matchers/toHaveSingleSortedColumn.js — a grid-specific invariant.
export function toHaveSingleSortedColumn(container) {
const sorted = container.querySelectorAll(
// Count headers claiming a direction — SC 4.1.2 consistency check.
'th[aria-sort="ascending"], th[aria-sort="descending"]'
);
const pass = sorted.length <= 1;
return {
pass,
message: () =>
`expected at most one sorted column, found ${sorted.length}`,
};
}
What the unit layer sees vs. misses
Permalink to "What the unit layer sees vs. misses"| Concern | jest-axe (jsdom) | Needs a browser test |
|---|---|---|
| Invalid role / missing required ARIA attr (SC 4.1.2) | Catches | — |
aria-sort present and valid value (SC 4.1.2) |
Catches | — |
Broken aria-labelledby reference (SC 1.3.1) |
Catches | — |
aria-sort correct after a sort interaction (SC 4.1.2) |
Catches (re-run axe) | — |
| Text / component contrast (SC 1.4.3, 1.4.11) | Misses (no layout) | Yes |
| Tab lands on the right cell / roving tabindex (SC 2.4.3) | Misses (no focus model) | Yes |
| Focus visible after sort (SC 2.4.7) | Misses | Yes |
| Live region actually announced (SC 4.1.3) | Misses | Manual SR pass |
Integration context
Permalink to "Integration context"This unit layer runs first in the automated accessibility testing pipeline, before the browser scan documented in setting up axe-core in a GitHub Actions pipeline. When you assert aria-sort transitions, you are unit-testing the exact contract specified in aria-sort attributes for accessible column filtering — the deep-dive that defines valid hosts and attribute timing. Because jest-axe cannot verify the focus behaviour behind that contract, the roving tabindex pattern still needs a browser or manual check.
Gotchas
Permalink to "Gotchas"1. getByRole('grid') throws on a native table
Permalink to "1. getByRole('grid') throws on a native table" Testing Library resolves roles from the accessibility tree: a <table> has role table, not grid, unless you explicitly set role="grid". A test that queries getByRole('grid') against a plain table throws with “unable to find an accessible element with the role grid”. Query table for native tables; reserve grid for components that genuinely implement composite widget navigation.
2. Forgetting expect.extend — silent misuse
Permalink to "2. Forgetting expect.extend — silent misuse" If you call expect(results).toHaveNoViolations() without expect.extend(toHaveNoViolations), Jest throws “toHaveNoViolations is not a function”. Put the expect.extend call in a shared setup file (setupFilesAfterEach) so every test file has the matcher and you cannot forget it per-file.
3. Trusting a static render for a stateful grid
Permalink to "3. Trusting a static render for a stateful grid"A grid that renders correctly on mount can break after a sort, filter, or row expansion. A suite with only a mount-time axe(container) call gives false confidence. Always add at least one test that drives the primary interaction with user-event and re-runs axe on the resulting tree, as shown above.
FAQ
Permalink to "FAQ"Why does jest-axe not catch focus-order bugs in my grid?
jest-axe runs in jsdom, which has no rendering or real focus model. It evaluates a static snapshot of the accessibility tree, so it can confirm a roving tabindex attribute is present but cannot press Tab, cannot observe which element actually receives focus, and cannot see a focus ring. Focus order and keyboard operability are dynamic behaviours that need a real browser test in Playwright or Cypress.
Should I skip the color-contrast rule in jest-axe?
You do not need to skip it manually because axe-core disables color-contrast automatically in jsdom, which has no layout engine and reports every colour as unset. Any contrast pass at the unit layer is meaningless. Assert contrast in a real-browser scan instead, where computed styles exist, and keep jest-axe focused on structural rules like roles, names, and ARIA reference integrity.
Do I need role=grid or is a native table enough for my component test?
Test whatever your component actually renders. If it is a native table with aria-sort on the headers, assert the table and columnheader roles and do not add role="grid". Only assert role="grid" when the component implements composite widget navigation with arrow keys. Testing for role="grid" on a plain table would encode the wrong contract and could push you toward an application-mode pattern users do not expect.
Related
Permalink to "Related"- Automated accessibility testing pipelines — where this unit layer sits in the full pipeline
- aria-sort attributes for accessible column filtering — the attribute contract these tests assert
- Setting up axe-core in a GitHub Actions pipeline — the browser layer that covers the contrast and focus gaps jest-axe cannot reach