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.


Permalink to "Related"

Back to Automated Accessibility Testing Pipelines