testing and automating accessibility

Debugging jest-axe Violations in CI

A jest-axe test that is green on your laptop and red in CI is almost never a flaky framework—it is a timing, scope, or environment difference that your local run happened to paper over. This guide is a triage manual: how to read the violations array axe returns, how to stop testing the DOM before it has finished rendering, when to scope axe to document instead of container, and which failures are jsdom artifacts you should stop chasing in jest-axe entirely. It sits under Component Testing with jest-axe, builds directly on the runnable specs in Testing React Components with jest-axe, and feeds the pipeline rules covered in Gating Accessibility in CI/CD Pipelines.

The rules most often implicated are 4.1.2 Name, Role, Value (a control lost its name during an async render) and 1.3.1 Info and Relationships (a reference broke because the target mounted in a portal axe never scanned). Both are structural rules that the axe-core rules engine can evaluate perfectly inside jsdom—so when they fail only in CI, the cause is what axe saw, not the rule itself.

Triage tree for a jest-axe failure that is green locally but red in CI A top-to-bottom checklist. Starting from "green locally, red in CI", each check that comes back clean drops down to the next; if a check is the culprit it branches right to the fix. Did axe run after the DOM settled? If no, gate axe behind findBy or waitFor. Is the content in a portal? If yes, scope axe to document.body. Is a timer mid-flight? If yes, drive it with fake timers. Can jsdom evaluate this rule? If no, move colour and geometry checks to a real browser. Clearing all four reaches a deterministic gate that passes on every machine. check clear → drop down · culprit → branch right to the fix no yes yes no Green locally · red in CI triage the disagreement, not the framework Did axe run after the DOM settled? rules 4.1.2 · 1.3.1 flip on half-rendered DOM Is the content in a portal? modal · tooltip · toast render off-container Is a timer mid-flight? auto-dismiss · debounce · animation gate Can jsdom evaluate this rule? no layout or paint pipeline Deterministic gate passes on every machine Gate axe behind findBy* / waitFor wait until the real DOM is ready Scope axe to document.body container never sees the portal Drive it with fake timers advance time before scanning Move colour / geometry to a browser disable in jsdom · gate in Playwright

Prerequisites

This guide assumes you already have a working jest-axe setup and are debugging a failure, not building the harness. You should have:

  • A React project running Jest in the jsdom environment with jest-axe, @testing-library/react, and @testing-library/user-event installed—the setup covered in Testing React Components with jest-axe.
  • The toHaveNoViolations matcher registered via expect.extend(toHaveNoViolations) in your Jest setup file.
  • A CI job (GitHub Actions or similar) that runs your Jest suite, and access to its logs—you will read the printed violation output to diagnose most failures here.

If any of that is missing, wire it up first; the techniques below all assume axe already runs and the disagreement is between environments, not a broken installation.

Reading the Violations Array

When toHaveNoViolations fails it prints a formatted summary, but the structured data behind it is what you debug from. Each entry in results.violations is a rule failure; each node inside it is a specific element that broke the rule.

const results = await axe(container);

results.violations.forEach((v) => {
  console.log(v.id);             // rule id, e.g. 'button-name'
  console.log(v.impact);         // 'minor' | 'moderate' | 'serious' | 'critical'
  console.log(v.help);           // one-line description of the rule
  v.nodes.forEach((n) => {
    console.log(n.target);       // CSS selector path to the offending element
    console.log(n.html);         // the element's outer HTML at failure time
    console.log(n.failureSummary); // exactly what to fix, e.g. "Fix any of the following..."
  });
});

The four fields that resolve most cases: id tells you which rule (look it up in the axe-core ruleset), impact tells you severity, target is the selector that points at the element, and failureSummary spells out the remediation. In CI, the printed html is your most valuable clue—it shows the DOM as axe saw it, which is frequently a half-rendered state your local timing skipped past. Log the full violation in CI rather than only the matcher's summary when a failure is hard to reproduce.


Reading a violation object without guessing Four fields of an axe violation and what each tells you. The id names the rule and links to its documentation. The impact ranks urgency. The nodes array carries the selector and the failing HTML. And the failureSummary states, in plain language, what would have to change for the node to pass. id the rule name, and the link to its documentation impact critical, serious, moderate, minor — how to triage it nodes[].target and html the selector and the failing element, ready to paste into DevTools nodes[].failureSummary what would have to change, in plain language

Async Rendering: Wait Before You Run axe

The classic CI-only failure is a race. Your local machine renders the component fast enough that the DOM is settled by the time axe runs; CI runs slower (or faster, exposing a different order), and axe inspects an intermediate state—a button before its label loads, an input before its error mounts. The fix is to never call axe until the DOM you intend to test exists.

import { render, screen, waitFor } from '@testing-library/react';
import { axe } from 'jest-axe';

test('runs axe only after async content settles', async () => {
  const { container } = render(<UserProfile id="42" />);

  // BAD: axe here may inspect a loading skeleton with unnamed controls
  // expect(await axe(container)).toHaveNoViolations();

  // GOOD: wait for the real content to appear first
  await screen.findByRole('heading', { name: /ada lovelace/i });

  // Or wait on a stable post-load condition before scanning
  await waitFor(() => expect(screen.queryByText(/loading/i)).not.toBeInTheDocument());

  expect(await axe(container)).toHaveNoViolations();
});

findBy* queries retry until the element appears or time out; waitFor retries an arbitrary assertion. Use whichever expresses "the DOM is ready." Running axe behind an explicit wait removes the entire class of order-dependent CI failures—if the component has finished rendering before axe runs, the result is deterministic across machines.


Portals: Scope to document, Not container

React portals render outside the React subtree—typically to document.body. Modals, tooltips, toasts, and popovers commonly use them. If you scope axe to the container returned by render, the portal content lives elsewhere in the DOM and axe never sees it. Locally you might not notice; in CI a portal-rendered violation surfaces only when something else changes scope.

test('modal in a portal is scanned by axe', async () => {
  const user = userEvent.setup();
  render(<DeleteDialog />); // dialog portals to document.body

  await user.click(screen.getByRole('button', { name: /delete/i }));
  await screen.findByRole('dialog');

  // container would MISS the portal—scan the whole document instead
  expect(await axe(document.body)).toHaveNoViolations();
});

The asymmetry to remember: Testing Library queries like getByRole search the entire document by default, so they find portal content fine—which is exactly why a portal can pass your role/name assertions while axe(container) silently scanned nothing. When a component portals, scope axe to document.body (or the portal root). When it does not, keep the tighter container scope so axe stays focused on the component under test rather than test scaffolding.


Flaky Timing and Fake Timers

Some CI flake comes from timers, not rendering: a toast that auto-dismisses on a setTimeout, a debounced validation, an animation gate. If axe runs while a timer-driven element is mid-transition, the result is nondeterministic. Control time explicitly instead of hoping the wall clock cooperates.

test('scans the toast before its auto-dismiss timer fires', async () => {
  jest.useFakeTimers();
  const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
  render(<SaveButton />);

  await user.click(screen.getByRole('button', { name: /save/i }));
  await screen.findByRole('status'); // toast is present

  expect(await axe(document.body)).toHaveNoViolations();

  jest.runOnlyPendingTimers(); // let the dismiss timer fire deterministically
  jest.useRealTimers();
});

When you adopt fake timers, wire userEvent.setup({ advanceTimers }) so user interactions still flush microtasks—otherwise queries hang. The principle is the same as the async section: make the DOM state deterministic before axe inspects it, and CI stops disagreeing with your laptop.


Why a test passes locally and fails in CI Three environment differences that produce green locally and red in the pipeline. Fonts differ, so text metrics and truncation differ. Animation and timing differ under a loaded runner, so an unawaited render is sometimes captured mid-flight. And the CI viewport may differ from the local default, exposing a state the local run never rendered. Different fonts text metrics and truncation differ, changing what is rendered Different timing a loaded runner captures an unawaited render mid-flight A different viewport exposes states the local default never rendered

What jsdom Simply Cannot Test

Some "CI failures" are not failures—they are jsdom limitations producing noise. jsdom has no layout or paint pipeline, so any rule needing computed geometry or color cannot run meaningfully:

  • color-contrast (1.4.3 Contrast (Minimum)) — no computed colors; disable it in jest-axe config and verify in a browser.
  • target-size (2.5.8 Target Size (Minimum)) — no real pixel dimensions exist.
  • Visibility/occlusion rules — geometry is effectively zero, so "is this visible" is unanswerable.

Disable these explicitly so they never produce a misleading result:

const results = await axe(container, {
  rules: { 'color-contrast': { enabled: false } }, // jsdom can't compute it—own it in the browser
});

Chasing these in jest-axe wastes triage time. Move them to a real-browser run and gate them there—that split between structural jsdom checks and rendered-browser checks is the whole reason end-to-end accessibility testing with Playwright exists alongside your unit suite. The pipeline-level strategy for combining both layers lives in Gating Accessibility in CI/CD Pipelines.


How to Verify

Reproduce the CI condition locally before declaring a fix. Force the slower, deterministic path and run the exact command CI runs:

CI=true npx jest --runInBand --ci

--runInBand removes worker parallelism (a frequent source of order-dependent flake), and --ci makes Jest treat the run like the pipeline does. If a test still passes locally but fails in CI, log the full violation—id, impact, target, html, failureSummary—from the CI run and read the html to see the DOM state axe captured; it usually reveals an async or portal scope gap.

Confirm the fix by re-running the same command several times: a genuinely deterministic test passes every time. Finally, for any rule you disabled as a jsdom limitation, verify it in a real browser and add a manual NVDA or VoiceOver spot-check so the coverage you removed from jest-axe is not simply lost—axe automation catches the machine-checkable subset, and a keyboard-and-screen-reader pass covers the contracts it cannot.


Common a11y Mistakes

  • Scoping axe(container) for portal content — axe scans nothing while your role queries still pass, hiding the violation. Scope to document.body for portals.
  • Running axe before async DOM settles — produces order-dependent CI failures. Gate axe behind findBy*/waitFor.
  • Leaving color-contrast enabled in jsdom — yields misleading results; disable it and verify in a browser.
  • Parallel workers masking flake — debug with --runInBand so failures are reproducible.
  • Reading only the matcher summary — the structured violations array (target, html, failureSummary) tells you exactly what and where.

Conclusion

CI-only jest-axe failures decompose into a short checklist: did axe run after the DOM settled, did it scope to the place the content actually rendered, were timers deterministic, and is the failing rule something jsdom can even evaluate. Read the violation nodes, fix the scope and timing, push layout and color to the browser, and your component suite becomes a reliable gate instead of an intermittent annoyance.


Frequently Asked Questions

Why does my jest-axe test fail in CI but pass locally? Almost always timing or scope. CI runs at a different speed, so axe may inspect a half-rendered DOM your local run skipped past, or worker parallelism exposes an order your laptop didn't. Gate axe behind findBy*/waitFor, run with --runInBand to reproduce, and read the violation's html to see the state axe captured.

My modal passes getByRole but axe reports nothing—why? The modal renders into a portal on document.body. Testing Library queries search the whole document, so getByRole finds it, but axe(container) only scans the render container and misses the portal entirely. Scope axe to document.body when a component portals.

Which violation fields should I log to debug CI failures? Log id (the rule), impact (severity), target (selector to the element), html (the DOM as axe saw it), and failureSummary (the remediation). The html field is the most useful for CI-only failures because it reveals the exact intermediate state that triggered the rule.

Should I just disable rules that fail flakily? Only if the rule cannot run in jsdom (like color-contrast or target-size), and then verify it in a real browser instead. If a structural rule flakes, the cause is async or scope—fix that. Disabling a structural rule to silence flake removes real coverage.