cypress-axe for Data Tables

Permalink to "cypress-axe for Data Tables"

cypress-axe adds two commands to Cypress: cy.injectAxe(), which loads axe-core into the page under test, and cy.checkA11y(), which runs a scan and fails the test on violations. For teams whose end-to-end suite is in Cypress, it is the quickest way to add accessibility checks to data table flows. Its defaults, though, produce unhelpful output — a failure message that says “N accessibility violations were detected” and little else — and teams often scan only the page as loaded.

This recipe scopes scans to tables, adds state commands, and logs violations in a form engineers can act on. It belongs to accessibility testing recipes.

Spec reference

Permalink to "Spec reference"

cypress-axe API:

  • cy.injectAxe() — call after every cy.visit() (the page reload removes axe).
  • cy.configureAxe(options) — set rules and checks.
  • cy.checkA11y(context, options, violationCallback, skipFailures) — context is a selector or { include, exclude }; options accepts runOnly, rules, includedImpacts; the callback receives violations for custom logging.

Table-related axe rules that matter most: th-has-data-cells, td-headers-attr, td-has-header (large tables), empty-table-header, table-duplicate-name, scrollable-region-focusable, aria-required-children for grids, and button-name for icon buttons in rows.

Criteria covered are structural: SC 1.3.1, 4.1.2, parts of 1.4.3 and 2.1.1 (focusable scroll regions).

The cypress-axe loop for a table Steps of a cypress-axe test for a data table: visit, inject axe, perform the state's actions, wait for settling, and check the table with a logging callback. The cypress-axe loop for a tableVisitcy.visit('/invoices')fresh pageInjectcy.injectAxe()after every visitReach statecustom command: sort, filter, expandkeyboard where possibleSettlewait for status text or row countno cy.wait(ms)Checkcy.checkA11y('#invoice-table', OPTIONS, logViolations)scoped
Inject after every visit — the most common reason cypress-axe "finds nothing" is a reload that removed axe.

When to use cypress-axe — and when not to

Permalink to "When to use cypress-axe — and when not to"

Use it when your end-to-end tests are in Cypress and already reach the table states you care about. Adding checkA11y at those points costs a line each.

If you are starting fresh with no end-to-end suite, choose the test runner for other reasons; the Playwright recipe in axe scans of grid states with Playwright does the same job.

The misapplication to name is skipFailures: true left on after debugging. It logs violations and passes the test, so regressions accumulate silently in CI logs no one reads.

Annotated code example

Permalink to "Annotated code example"
// cypress/support/e2e.js
import 'cypress-axe';

Cypress.Commands.add('sortTableBy', (label) => {
  cy.findByRole('button', { name: label }).focus().type('{enter}');     // keyboard
  cy.findByRole('status').should('contain.text', `Sorted by ${label}`);  // settled
});

Cypress.Commands.add('filterTable', (q) => {
  cy.findByRole('searchbox', { name: 'Filter invoices' }).clear().type(q);
  cy.findByRole('status').should('contain.text', 'shown');
});
// cypress/support/a11y.js — readable violation output
export const AXE_OPTIONS = {
  runOnly: { type: 'tag', values: ['wcag2a', 'wcag2aa', 'wcag21aa', 'wcag22aa'] },
};

export function logViolations(violations) {
  cy.task('log', `${violations.length} accessibility violation(s)`);
  cy.task('table', violations.map((v) => ({
    rule: v.id,
    impact: v.impact,
    nodes: v.nodes.length,
    target: v.nodes.map((n) => n.target.join(' ')).slice(0, 3).join(' | '),
    help: v.help,
  })));
}
// cypress/e2e/invoice-table.a11y.cy.js
import { AXE_OPTIONS, logViolations } from '../support/a11y';

describe('invoice table accessibility', () => {
  beforeEach(() => {
    cy.visit('/invoices');
    cy.injectAxe();                                      // after every visit
    cy.findAllByRole('row').should('have.length.greaterThan', 1);
  });

  it('loaded state', () => {
    cy.checkA11y('#invoice-table', AXE_OPTIONS, logViolations);
  });

  it('sorted by Amount', () => {
    cy.sortTableBy('Amount');
    cy.checkA11y('#invoice-table', AXE_OPTIONS, logViolations);
  });

  it('filtered to no results', () => {
    cy.filterTable('zzz-no-match');
    cy.checkA11y({ include: ['#invoice-table', '.empty-state'] }, AXE_OPTIONS, logViolations);
  });
});
// cypress.config.js — register the log/table tasks
setupNodeEvents(on) {
  on('task', {
    log(msg) { console.log(msg); return null; },
    table(rows) { console.table(rows); return null; },
  });
}

The findByRole commands come from @testing-library/cypress; they double as accessibility assertions, because a button that cannot be found by its role and name is itself a finding — see Testing Library role queries for grids.

Keyboard & AT behaviour

Permalink to "Keyboard & AT behaviour"

cypress-axe checks structure. Cypress can drive the keyboard (type('{enter}'), realPress with cypress-real-events for native key events), which the state commands above use so the path to each state is at least keyboard-reachable.

Check cypress-axe Cypress keyboard Needs elsewhere
Header wiring ✓ — —
Sort button reachable by role — ✓ via findByRole —
Sort activates with Enter — ✓ —
Focus stays on the sort button — ✓ should('have.focus') —
Announcement spoken — — Screen reader smoke test
Default cypress-axe output versus logged violations Comparison of the default cypress-axe failure message against a violation callback that prints a table of rules, impacts, targets and help text. Default cypress-axe output versus logged violations✗ Default failure"3 accessibility violations were detected"No rule names in CI logsNo element targetsEngineer re-runs locally to find out✓ Logged violationsRule, impact and node count per rowFirst three targets as selectorsaxe help text inlineReadable directly in CI output
A failing test should tell the engineer what to fix without opening DevTools.

Integration context

Permalink to "Integration context"

Use the same AXE_OPTIONS as every other scanner in the pipeline — configuring axe-core rules and handling false positives — so a rule decision made once applies everywhere. What the table rules check is explained in semantic HTML table construction.

For component-level checks in Cypress Component Testing, the same commands work on mounted components, which is often faster than full-page runs for design-system tables.

Table rules and what triggers them Matrix of axe rules relevant to data tables, what markup triggers each, and the usual fix. Table rules and what triggers themRuleTriggered byFixth-has-data-cellsHeader with no cellsRemove or fix the headertd-headers-attrheaders id not foundCorrect the id listempty-table-headerBlank action column thVisually hidden header textscrollable-region-focusableScrolling wrappertabindex, role, namebutton-nameIcon-only row buttonName with action and record
Most table findings map to one missing element or attribute.

Running cypress-axe in CI

Permalink to "Running cypress-axe in CI"

Run the accessibility specs in the same CI job as the rest of the Cypress suite, headless, against a build with seeded data. Two settings matter more than the rest. First, keep video and screenshotOnRunFailure on for these specs: a screenshot of the table state at the moment of failure makes violation output much easier to interpret. Second, split accessibility specs into their own folder (cypress/e2e/a11y/) so they can be run on their own when a design-system upgrade lands, without the full end-to-end suite.

Report the violation table in the job summary rather than leaving it in raw logs. A short script can read the logged JSON and write a Markdown table of rule, impact, state and target to the CI summary, which is the first thing reviewers see on the pull request.

Keep the suite fast by reusing one cy.visit per state where the app allows it, and by scoping scans; unscoped scans of large pages are the slowest part of most cypress-axe suites.

Gotchas

Permalink to "Gotchas"

Forgetting to re-inject. Any cy.visit() or full reload removes axe from the page. Put injectAxe in the same beforeEach as the visit, and after any navigation inside a test.

Detached DOM. Re-rendered tables can detach the element Cypress captured; query by role again after each action.

Impact filtering. includedImpacts: ['critical', 'serious'] hides moderate issues that are still WCAG failures. Filter by tags, not impact.

Design system notes

Permalink to "Design system notes"

Publish the Cypress state commands and violation logger with the design system’s testing utilities, alongside the Playwright helpers, so teams on either runner get the same scoped, state-based checks and the same readable output.

Testing checklist

Permalink to "Testing checklist"

FAQ

Permalink to "FAQ"
How do I use cypress-axe on a data table?

Visit the page, call cy.injectAxe(), bring the table into the state you want to test, wait for it to settle, and call cy.checkA11y with the table’s selector as the context and your shared axe options.

Why does cypress-axe report nothing after navigation?

Because a page load removes axe from the page. Call cy.injectAxe() again after every visit or reload.

How can I make cypress-axe failures easier to read?

Pass a violation callback to checkA11y that logs each rule, impact, a few target selectors and the help text, using a Cypress task to print a table in the terminal.

Can cypress-axe check keyboard behaviour?

No. It runs axe-core, which checks structure. Use Cypress keyboard commands — or the cypress-real-events plugin for native key events — with focus assertions for keyboard behaviour.

Should I filter violations by impact?

Prefer filtering by WCAG tags. Moderate-impact violations can still be WCAG failures, and hiding them lets regressions through.

Permalink to "Related"

← Back to Accessibility Testing Recipes