testing and automating accessibility

Component Testing with jest-axe

jest-axe runs the axe-core rules engine against the DOM your React component renders, so accessibility violations fail your unit suite the same way a broken assertion does. Wired into Testing Library, it turns the abstract goal of "accessible components" into a concrete, fast, per-component gate that runs on every commit. This guide sits under Testing and Automating Accessibility and focuses on the component layer: render a component, run axe against its container, assert zero violations, and then test the dynamic states—open modals, error forms, expanded menus—where regressions actually hide. Axe-core enforces machine-checkable subsets of 1.3.1 Info and Relationships, 4.1.2 Name, Role, Value, and 3.3.2 Labels or Instructions, while you pair it with role/name queries to assert the contracts axe cannot evaluate on its own.

The trade-off to understand up front: jest-axe runs inside jsdom, which has no layout engine and no rendering. That makes it ideal for structural rules (missing labels, broken aria-* references, invalid roles) and useless for anything requiring computed geometry or color—those checks belong in End-to-End Accessibility Testing with Playwright. Knowing which layer owns which rule is the difference between a green suite that means something and one that lies.

jest-axe component test pipeline Four stages, left to right: render the component with Testing Library, run axe against its container, assert no violations, and gate the result in CI. A dashed branch drops from the axe stage to a note that color and layout rules cannot run in jsdom and are deferred to Playwright. render(<C />) Testing Library axe(container) axe-core rules expect(results) .toHaveNoViolations CI gate color + layout rules can't run in jsdom defer to Playwright (no paint engine)

Wiring jest-axe into Testing Library

jest-axe ships an axe runner and a custom matcher, toHaveNoViolations. The matcher must be registered once via expect.extend before any test asserts against it—do this in your global setup file so every spec inherits it.

// test-setup.ts — loaded via setupFilesAfterEach in jest.config
import '@testing-library/jest-dom';
import { toHaveNoViolations } from 'jest-axe';
import { cleanup } from '@testing-library/react';

// Register the matcher globally so every spec can call expect(results).toHaveNoViolations()
expect.extend(toHaveNoViolations);

// Unmount components between tests so axe never inspects leaked DOM from a prior render
afterEach(() => cleanup());
// jest.config.js
module.exports = {
  testEnvironment: 'jsdom', // axe-core needs a DOM; jsdom provides one (without layout)
  setupFilesAfterEach: ['<rootDir>/test-setup.ts'],
};

The jsdom environment is mandatory: axe-core walks a live DOM tree, so a node-only environment has nothing to inspect. With the matcher registered, every test that renders a component can run axe and assert against the structured result. This is the foundation for the step-by-step walkthrough in Testing React Components with jest-axe.

If your project runs on Vitest rather than Jest, the wiring is nearly identical—jest-axe is framework-agnostic because it only depends on expect.extend, which Vitest also implements. Register the matcher in a setupFiles entry, set test.environment to 'jsdom' (or 'happy-dom'), and the same axe/toHaveNoViolations pair works unchanged. The one caveat is TypeScript: import the matcher's type augmentation so toHaveNoViolations type-checks against Vitest's Assertion interface rather than Jest's.

To stop every spec from repeating the same render-and-assert boilerplate, factor the common path into a small helper. Centralising the shared rule configuration here—rather than copy-pasting a rules object into each test—means a project-wide decision (like disabling color-contrast in jsdom) lives in exactly one place:

// test/a11y.ts — a reusable axe runner every component spec can import
import { axe } from 'jest-axe';

// One place to encode "these rules can't run in jsdom"; every caller inherits it
export const runAxe = (el: Element) =>
  axe(el, { rules: { 'color-contrast': { enabled: false } } });

A spec then reads expect(await runAxe(container)).toHaveNoViolations(), and the day you add a new jsdom-incapable rule you change one line instead of grepping the suite.


Rendering a Component and Running axe Against the Container

The core pattern is three lines: render the component, pass the rendered container to axe, and assert no violations. Scoping axe to container (not document) keeps the check focused on the component under test rather than test-runner scaffolding.

import { render } from '@testing-library/react';
import { axe } from 'jest-axe';
import { PriceCard } from './PriceCard';

test('PriceCard has no axe violations', async () => {
  const { container } = render(<PriceCard plan="pro" price={49} />);

  // axe() is async: it loads rules and walks the DOM, resolving to a results object
  const results = await axe(container);

  // The matcher fails with a readable diff listing rule id, impact, and offending nodes
  expect(results).toHaveNoViolations();
});

This catches the rules axe can evaluate without layout: a <button> with no accessible name, an <img> missing alt, an aria-labelledby pointing at a non-existent id, a list item outside a list, duplicate id attributes feeding aria-* references. These map directly to 4.1.2 Name, Role, Value and 1.3.1 Info and Relationships. Because axe() is asynchronous, always await it—an unawaited promise produces a passing test that checked nothing.

One caveat: a component that renders to a React portal lands outside container, so scoping to container will silently skip it. That scope decision is the single most common source of false-green tests, covered in depth in the CI debugging guide below.


Scoping and Disabling Specific Rules Per Test

Axe-core runs its full ruleset by default. Sometimes you need to narrow it—either to assert one specific rule in isolation, or to suppress a rule that cannot run meaningfully in jsdom. Pass a configuration object as the second argument to axe.

test('Avatar disables the contrast rule it cannot evaluate in jsdom', async () => {
  const { container } = render(<Avatar name="Ada Lovelace" />);

  const results = await axe(container, {
    rules: {
      // color-contrast needs computed layout/paint, which jsdom lacks—turn it off
      // rather than let it false-negative. Real contrast lives in Playwright/CI.
      'color-contrast': { enabled: false },
    },
  });

  expect(results).toHaveNoViolations();
});

You can also restrict the run to specific tags or a single rule when writing a targeted regression test:

const results = await axe(container, {
  runOnly: { type: 'rule', values: ['button-name'] }, // assert only that buttons are named
});

Disable rules deliberately and document why. A blanket disable hides real defects; a scoped, commented disable communicates that the rule belongs to a different test layer. Resist suppressing a rule just to make a test pass—that converts an accessibility gate into decorative noise. For project-wide axe configuration shared across component and end-to-end suites, see Automated Accessibility Testing with axe-core.


Testing Dynamic Component States

A component's initial render is rarely where accessibility breaks. Regressions appear when a modal opens, a form surfaces validation errors, or a menu expands. Each of these is a distinct DOM state, and each needs its own axe pass. Drive the component into the state with Testing Library, wait for it to settle, then run axe.

import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { axe } from 'jest-axe';
import { SettingsDialog } from './SettingsDialog';

test('open dialog has no violations and a proper dialog contract', async () => {
  const user = userEvent.setup();
  const { container } = render(<SettingsDialog />);

  // Drive the component into its OPEN state—the initial render never showed the dialog
  await user.click(screen.getByRole('button', { name: /open settings/i }));

  // findByRole waits for the dialog to mount before axe inspects it
  const dialog = await screen.findByRole('dialog');

  // Run axe on the now-open DOM; this catches a focus-trap container with a missing name
  const results = await axe(container);
  expect(results).toHaveNoViolations();

  // aria-modal is a contract axe will not enforce for you; assert it explicitly
  expect(dialog).toHaveAttribute('aria-modal', 'true');
});

The same discipline applies to a form in its error state: submit invalid data, wait for the error region, then run axe to confirm each invalid field carries aria-invalid and an aria-describedby that resolves to the visible error text—the structural side of 3.3.1 Error Identification. An expanded disclosure menu needs aria-expanded="true" and a aria-controls target present in the DOM. Run axe once per meaningful state; a single render-time pass leaves the most fragile transitions untested.


Combining axe With Role and Name Queries

Axe-core verifies that the DOM is structurally valid—but it does not know your intent. It cannot tell you that the dialog should be labeled "Edit profile," that the error message should be associated with the email field, or that the active tab should be Account. Those are application contracts, and you assert them with Testing Library's getByRole and accessible-name queries alongside the axe pass.

test('error message is associated with the field it describes', async () => {
  const user = userEvent.setup();
  const { container } = render(<EmailForm />);

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

  // Testing Library resolves the field by its accessible name—the same name AT exposes
  const email = await screen.findByRole('textbox', { name: /email/i });

  // axe confirms the wiring is structurally valid (id targets resolve, role is correct)
  expect(await axe(container)).toHaveNoViolations();

  // Then assert the semantic contract axe cannot: invalid state + the RIGHT description
  expect(email).toHaveAttribute('aria-invalid', 'true');
  expect(email).toHaveAccessibleDescription(/enter a valid email/i);
});

The division is clean: axe owns "is this structurally legal," getByRole/name queries own "does it mean the right thing." Querying by role and accessible name also forces your tests to interact with the component the way assistive technology does, which surfaces naming regressions that a data-testid selector would sail past—reinforcing 4.1.2 Name, Role, Value at the assertion level.


What jsdom can and cannot decide Two lists. In jsdom axe can evaluate structure, names, roles, ARIA validity and label associations, because those come from the DOM alone. It cannot evaluate contrast, visibility, layout, focus appearance or anything requiring real layout and painting, because jsdom computes no geometry. A note directs those checks to a real browser run under Playwright. jsdom can decide roles, names, ARIA validity label associations and ids structure and relationships fast, on every commit Needs a real browser colour contrast visibility and overlap focus appearance and order anything with geometry jsdom computes no layout, so a contrast pass there means the rule was skipped, not that it passed.

jsdom Limitations and What to Push to Playwright

jsdom implements the DOM API but has no layout or paint pipeline. Element geometry is effectively zero, computed styles are incomplete, and nothing is ever truly "visible" in a rendered sense. Several axe-core rules depend on exactly that information and therefore cannot produce trustworthy results in jest-axe:

  • color-contrast — needs computed foreground/background colors and font metrics; runs as a no-op or false result in jsdom. Owns 1.4.3 Contrast (Minimum).
  • Visibility-dependent rules — anything that checks whether an element is on-screen, occluded, or sized below a target threshold has no geometry to read.
  • target-size (2.5.8 Target Size (Minimum)) — requires real pixel dimensions jsdom never computes.
  • Reflow and zoom behavior (1.4.10 Reflow) — inherently a rendered-viewport concern.
Which test layer owns which rule Two panels. On the left, jest-axe running in jsdom owns structural rules it can check without paint: accessible name and role, information and relationships, labels and instructions, and broken ARIA references. On the right, Playwright running in a real browser owns rules that need layout and paint: color contrast, target size, reflow and zoom, and focus visibility. A dashed arrow points from the jsdom panel to the browser panel, labelled defer. jest-axe in jsdom Playwright in a browser fast · structural · every commit rendered · real pixels · slower defer Accessible name & role — 4.1.2 Info & relationships — 1.3.1 Labels & instructions — 3.3.2 Broken ARIA refs, duplicate ids Color contrast — 1.4.3 Target size — 2.5.8 Reflow & zoom — 1.4.10 Focus visibility & occlusion

Disable these in your jest-axe config so they cannot false-negative, and assert them in a real browser instead. End-to-End Accessibility Testing with Playwright runs the same axe-core engine against Chromium, where layout and color exist, making it the correct home for contrast, target size, and focus-visibility checks. Use component tests for fast, structural feedback on every state; use end-to-end tests for the rendered truth.

How to verify: Run npx jest --runInBand locally and confirm the suite fails loudly when you delete a label or break an aria-describedby reference—if it stays green, your scope or await is wrong. Then run an end-to-end axe pass for the layout/color rules jsdom skipped, and finish with a manual sweep in NVDA or VoiceOver to confirm the announced names and states match your role/name assertions.


Choosing Which Components to Cover

A component test suite that scans everything is slow and, paradoxically, less useful than one that scans deliberately: when every component has a passing scan, nobody looks at which states were actually rendered. Coverage decisions matter more than coverage counts.

Prioritise three groups. First, anything that renders a form control, because naming and description relationships are the most common source of real violations and the easiest to break during a refactor. Second, anything with more than one visual state — disclosures, tabs, dialogs, anything with an error or empty variant — because each state is separate markup that no other test renders. Third, anything wrapping a third-party component, since your test is the only thing that will notice when an upgrade changes its output.

Deprioritise pure presentation. A card that renders a heading and a paragraph has nothing axe can meaningfully check, and scanning it adds runtime without adding signal.

// One component, three scans — the states are what carry the risk.
test.each(['default', 'expanded', 'error'] as const)('%s state has no violations', async (state) => {
  const { container } = render(<Disclosure initialState={state} />);
  expect(await axe(container)).toHaveNoViolations();
});

The other half of the decision is what to assert alongside the scan. A violation-free component can still be unusable, so pair each scan with a role-and-name query for the controls that matter. The scan proves nothing is structurally wrong; the query proves the control is reachable the way a user would reach it. Neither is sufficient, and together they cover most of what a component test can honestly claim.

Reporting Failures Usefully

A jest-axe failure prints the violation array, which is complete and not especially readable at four in the afternoon. Two small additions make the output actionable without any extra tooling.

Configure the matcher's serialiser so each violation prints its rule id, impact, failing selector and help URL rather than the whole object graph — the library exposes a formatter hook for exactly this. And name the test after the accessibility contract it protects rather than the component, so the failure line itself explains what regressed: "checkout form: every error message is associated with its field" tells the next reader what to restore, while "CheckoutForm renders" tells them nothing at all.

Key Takeaways

  • Register toHaveNoViolations once via expect.extend in global setup; require the jsdom environment.
  • The base pattern is renderawait axe(container)expect(results).toHaveNoViolations(); always await.
  • Scope axe to the rendered container, but remember portals escape it—scope to document when testing portal content.
  • Test every meaningful dynamic state (open modal, error form, expanded menu) with its own axe pass.
  • Pair axe with getByRole/accessible-name queries to assert the semantic contracts axe cannot check.
  • Disable layout- and color-dependent rules in jsdom and push them to Playwright.

Keeping the Suite Fast

Component scans are cheap individually and add up quickly, because axe walks the rendered tree for every one. Two habits keep a large suite comfortable.

Scope each scan to the rendered container rather than document, unless the component portals content outside it — a scan of the whole document in every test re-checks the same test harness markup hundreds of times. And disable rules that cannot produce a meaningful result in jsdom, notably color-contrast, which is skipped there anyway but still costs traversal time. Neither habit reduces coverage; both reduce the runtime that makes people stop running the suite locally.

Frequently Asked Questions

Why does my jest-axe test pass even though the component is clearly inaccessible? The most common causes are an unawaited axe() call (the promise never resolves before the assertion), scoping to container when the content renders into a portal on document.body, or running axe before the dynamic state has mounted. Confirm you await axe(...), choose the right scope, and use findBy/waitFor to let async DOM settle first.

Can jest-axe catch color-contrast problems? No—jsdom has no layout or rendering, so the color-contrast rule cannot compute foreground and background colors and either no-ops or returns an unreliable result. Disable it in your jest-axe config and verify contrast in a real browser via Playwright.

Should I run axe on the whole document or just the component's container? Default to the rendered container so the check stays focused on the component under test. Switch to document only when the component renders into a portal (modals, tooltips, toasts) that mounts outside that container—otherwise axe silently skips the portal content.

Does passing jest-axe mean my component is fully accessible? No. Axe-core catches a machine-checkable subset of WCAG—missing names, broken references, invalid roles. It cannot judge whether a label is correct, whether focus moves sensibly, or whether contrast passes in jsdom. Pair axe with getByRole/name assertions, end-to-end tests, and manual screen-reader checks.

Where should I disable an axe rule—per test or globally? Disable per test when the rule is irrelevant to that specific component, and globally (in shared config) only for rules that can never run meaningfully in jsdom, like color-contrast. Always add a comment explaining why, so a suppressed rule reads as a deliberate layer decision rather than a hidden defect.