Automating axe Accessibility Checks in Vitest
Role-based queries prove that the specific controls you thought about are reachable, but they say nothing about the mistakes you did not think to check — an unlabeled form field three levels down, a list built from <div>s, an aria-* attribute with an invalid value. An automated scanner closes that gap by walking the whole rendered fragment and evaluating it against a rule set. This guide shows how to run axe-core inside a Vitest component suite via jest-axe, tune the rules so jsdom does not emit noise, scan content that renders through portals, and wire the result into a CI gate. It is for React teams already testing components with React Testing Library who want machine-detectable accessibility regressions to fail a pull request the same way a broken assertion does.
Root Cause Analysis
Automated accessibility scanning works because a large, well-defined class of WCAG failures is structural — determinable from the rendered DOM and its attributes alone, without any human judgment. Whether an <input> has an associated label, whether an aria-labelledby points at an element that exists, whether a role is spelled correctly and carries its required attributes, whether an image has alternative text — all of these are decidable by static analysis. axe-core encodes several dozen such rules and runs them against a DOM node, returning a list of violations with the offending elements and a link to remediation guidance. Wrapping it in jest-axe adds a toHaveNoViolations matcher so the scan reads like any other assertion.
The critical thing to understand is what the scanner cannot see, because that shapes how you configure it. axe-core was built to run in a real browser where getComputedStyle returns painted values. Under jsdom there is no layout and no real style resolution, so any rule that depends on rendered geometry or color — most importantly color-contrast — produces unreliable results. Left enabled, that rule either throws or reports phantom violations, and teams respond by distrusting the whole scan. The correct mental model is that jsdom axe covers the semantic and attribute rules perfectly and the visual rules not at all, so you disable the visual rules here and recover them in a real-browser layer. Getting this boundary right is the difference between a scan people trust and one they learn to ignore.
Reproducible Setup
Install jest-axe alongside your existing Testing Library stack and extend expect with its matcher in the shared setup file.
npm install -D jest-axe @types/jest-axe
// vitest.setup.ts
import '@testing-library/jest-dom/vitest';
import { expect } from 'vitest';
import { toHaveNoViolations } from 'jest-axe';
expect.extend(toHaveNoViolations);
Centralize a configured axe instance so every test uses the same rule set and you never repeat the jsdom-specific overrides. jest-axe exports configureAxe for exactly this.
// test/axe.ts
import { configureAxe } from 'jest-axe';
export const axe = configureAxe({
rules: {
// jsdom cannot resolve painted colors; recover this in a browser layer.
'color-contrast': { enabled: false },
},
});
Implementation
Step 1 — Scan a rendered component and assert zero violations. Render with Testing Library, hand the container to the configured axe, and await the result. This single assertion covers the whole subtree.
import { render } from '@testing-library/react';
import { axe } from '../test/axe';
import { SignupForm } from './SignupForm';
test('SignupForm has no detectable a11y violations', async () => {
const { container } = render(<SignupForm />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
Step 2 — Scan meaningful states, not just the initial render. A component can be accessible when empty and inaccessible once it shows an error, an expanded panel, or a loaded list. Drive it to each important state with user-event, then scan.
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { axe } from '../test/axe';
import { SignupForm } from './SignupForm';
test('SignupForm stays accessible in its error state', async () => {
const user = userEvent.setup();
const { container } = render(<SignupForm />);
await user.click(screen.getByRole('button', { name: /create account/i }));
// error summary + aria-invalid fields are now in the DOM
expect(await axe(container)).toHaveNoViolations();
});
Step 3 — Scan portaled content at the right root. Dialogs and menus render outside the component container, so scanning container misses them. Scan document.body when the component under test uses a portal.
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { axe } from '../test/axe';
import { SharePanel } from './SharePanel';
test('the opened dialog is accessible', async () => {
const user = userEvent.setup();
render(<SharePanel />);
await user.click(screen.getByRole('button', { name: /share/i }));
expect(screen.getByRole('dialog')).toBeInTheDocument();
// dialog is portaled to document.body, so scan the whole document
expect(await axe(document.body)).toHaveNoViolations();
});
Step 4 — Read a violation and turn it into a fix. When the matcher fails, jest-axe prints each violation’s rule id, impact, a human-readable description, and the exact offending nodes. Treat the help and helpUrl fields as the remediation instructions; do not suppress the rule.
// Example of inspecting results programmatically before asserting
const results = await axe(container);
for (const v of results.violations) {
console.log(v.id, v.impact, v.nodes.map((n) => n.target).flat());
}
expect(results).toHaveNoViolations();
Step 5 — Gate CI on the suite. Because these are ordinary Vitest tests, no special CI wiring is needed — a failing toHaveNoViolations fails the run like any assertion. Keep them in the fast component lane described in Vitest configuration and setup so every pull request pays the check.
npx vitest run --coverage
Verification
Prove the gate actually bites by introducing a known violation and confirming the suite goes red. Remove the label from an input in the component under test — change <label htmlFor="email">Email</label> plus <input id="email"> to a bare <input placeholder="Email"> — and run the axe test. It must fail with a label rule violation naming the offending input. That failing output is the evidence the scan is wired correctly and covers the subtree you expect. Restore the label and the suite returns to green. Repeat with an invalid attribute such as aria-labelledby="does-not-exist" to confirm the aria-valid-attr-value rule fires too.
A second verification is scope coverage: add a console.log(results.passes.length) and confirm the number of passing rule checks is non-trivial, which proves axe actually ran rather than short-circuiting on an empty node. If passes is zero and violations is zero, the scan saw nothing — usually a sign you passed a detached or empty container.
The loop these tests enforce is worth making explicit, because it is what turns a scanner into a gate rather than a report. A pull request renders the component, the scan evaluates every enabled rule, and either the result is clean and the merge proceeds, or a violation surfaces with a rule id and a remediation link that the author fixes before the gate opens. The fix is never to suppress the rule; a global disable trades one visible failure for a whole class of silent future ones. Keeping the loop tight — fix, re-run, green — is exactly what stops accessibility debt from accumulating between releases the way it does when the only check is a quarterly manual audit.
Troubleshooting
Symptom: every scan reports a color-contrast violation. Diagnosis: the color-contrast rule is still enabled and jsdom cannot resolve painted colors, so it reports noise. Fix: disable the rule in your shared configureAxe instance as shown above, and recover real contrast checking in a browser-driven layer such as the Playwright component testing area.
Symptom: the dialog is accessible in the app but the test finds no violations and also no dialog. Diagnosis: you scanned the render container, but the dialog is portaled to document.body, so the scan never saw it. Fix: scan document.body for any component that uses a portal, and assert the dialog is present with getByRole('dialog') before scanning so a missing portal fails loudly.
Symptom: the axe test is flaky, sometimes passing before content loads. Diagnosis: the component fetches data and you scanned before the DOM settled. Fix: await a findByRole for a stable element first, mock the request with MSW v2 so it resolves deterministically, and keep the act() boundary clean so the scan runs after React commits.
FAQ
Does a passing axe scan mean my component is accessible?
No, and treating it that way is the most common misuse of the tool. Automated rules catch a well-studied minority of WCAG issues — commonly cited around a third — because only structural and attribute-level failures are decidable by static analysis. Meaningful reading order, sensible alternative text, whether an error announcement is actually understandable, and whether the keyboard flow makes sense all require human judgment and a real screen reader. Read a green scan as “no machine-detectable regressions,” pair it with periodic manual review, and never let it stand in for lived testing with assistive technology.
Should I use jest-axe or vitest-axe?
Both wrap the same axe-core engine and expose an equivalent toHaveNoViolations matcher, so functionally they are interchangeable under Vitest. jest-axe is the older, more widely documented package and works fine in Vitest once you extend expect; vitest-axe is a thin fork with Vitest-native typings that some teams prefer for cleaner setup. Pick one, centralize a configureAxe instance so your rule overrides live in one place, and stay consistent across the suite rather than mixing them.
How do I handle a violation I cannot fix immediately?
Prefer fixing it, but when a third-party component or a staged migration makes that impossible, disable the specific rule for the specific test with a scoped configureAxe override rather than turning the rule off globally. Leave a comment linking a tracking issue so the suppression is auditable and temporary, exactly as you would quarantine a flaky test rather than delete it. A blanket global disable hides future regressions of the same class across every component, which is almost never what you want.
Will running axe on hundreds of components slow my CI noticeably?
Each scan adds tens of milliseconds because axe walks the DOM and evaluates every enabled rule, so a large suite does accumulate measurable time. Keep it proportionate by scanning once per meaningful DOM state rather than after every trivial interaction, and by disabling rules jsdom cannot evaluate so axe does no wasted work. These remain ordinary jsdom tests that parallelize linearly across CI workers, so even a few hundred scans stay in the seconds range and comfortably inside the fast component lane.
Related
- Back to Accessibility Testing for Components
- Testing ARIA roles with Testing Library — targeted role checks that complement the broad axe scan.
- Testing focus management in modals — verify the dialog behavior that axe cannot evaluate.
- Vitest configuration and setup — the fast lane these scans belong in.