testing and automating accessibility
Testing React Components with jest-axe
This is a hands-on walkthrough for adding accessibility assertions to real React components with jest-axe. We start from a clean setup, then write complete, copy-ready test files for two components where accessibility most often breaks: an accessible form with associated error messages, and a modal dialog with a proper role="dialog" and aria-modal. Along the way we assert the contracts axe-core enforces—broken references, missing names—and the contracts it cannot, like whether aria-invalid and aria-describedby are wired to the right field. This guide expands the patterns introduced in Component Testing with jest-axe into full, runnable specs.
The rules in play here are 4.1.2 Name, Role, Value (every control has a name and exposed state), 1.3.1 Info and Relationships (programmatic associations match the visible structure), and 3.3.1 Error Identification (errors are tied to their fields). Axe checks the structure; your role and name queries check the meaning.
Prerequisites
You need a React project with Jest configured for the jsdom environment plus the following dev dependencies:
npm install --save-dev jest-axe @testing-library/react \
@testing-library/user-event @testing-library/jest-dom
Register the axe matcher and the DOM matchers once in a global setup file referenced by setupFilesAfterEach in jest.config.js:
// test-setup.ts
import '@testing-library/jest-dom';
import { toHaveNoViolations } from 'jest-axe';
import { cleanup } from '@testing-library/react';
expect.extend(toHaveNoViolations); // makes expect(results).toHaveNoViolations() available
afterEach(() => cleanup()); // unmount between tests so axe never sees stale DOM
With that in place, every spec can render a component, run await axe(container), and assert the result. The jsdom environment is required because axe-core walks a live DOM tree.
Testing an Accessible Form for Violations
Consider a sign-up form where each field has a <label>, and validation errors are surfaced through aria-describedby pointing at a live error element. The accessibility-critical behavior is the wiring: when a field is invalid, it must set aria-invalid="true" and reference an error node that actually exists and contains the message text.
// SignupForm.tsx (abridged to the a11y-relevant parts)
export function SignupForm({ onSubmit }: { onSubmit: (email: string) => void }) {
const [error, setError] = useState('');
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const email = new FormData(e.currentTarget).get('email');
if (typeof email !== 'string' || !email.includes('@')) {
setError('Enter a valid email address.');
return;
}
setError('');
onSubmit(email);
}
return (
<form onSubmit={handleSubmit} noValidate>
<label htmlFor="email">Email</label>
<input
id="email"
name="email"
type="email"
aria-invalid={error ? true : undefined} // exposes invalid state to AT
aria-describedby={error ? 'email-error' : undefined} // links field → message
/>
{error && (
<p id="email-error" role="alert">{error}</p> /* role=alert announces on appear */
)}
<button type="submit">Create account</button>
</form>
);
}
The test renders the form, runs axe at rest, then drives it into its error state and runs axe again—two distinct DOM states, two passes:
// SignupForm.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { axe } from 'jest-axe';
import { SignupForm } from './SignupForm';
test('form is accessible at rest', async () => {
const { container } = render(<SignupForm onSubmit={jest.fn()} />);
// Catches a missing label or an input with no accessible name (4.1.2)
expect(await axe(container)).toHaveNoViolations();
});
test('invalid submit wires aria-invalid and aria-describedby to the field', async () => {
const user = userEvent.setup();
const { container } = render(<SignupForm onSubmit={jest.fn()} />);
await user.type(screen.getByRole('textbox', { name: /email/i }), 'not-an-email');
await user.click(screen.getByRole('button', { name: /create account/i }));
// The error node mounts asynchronously after state update—wait for it
const error = await screen.findByRole('alert');
expect(error).toHaveTextContent(/enter a valid email/i);
// Re-run axe on the ERROR state: confirms aria-describedby resolves to a real id
expect(await axe(container)).toHaveNoViolations();
// Assert the semantic contract axe cannot judge: invalid state + the RIGHT description
const email = screen.getByRole('textbox', { name: /email/i });
expect(email).toHaveAttribute('aria-invalid', 'true');
expect(email).toHaveAccessibleDescription(/enter a valid email/i);
});
If you removed the aria-describedby, axe would still pass (the field is otherwise valid), but toHaveAccessibleDescription would fail—demonstrating exactly why structural and semantic assertions must travel together. For the component-side patterns behind this wiring, see Form Handling with React Hook Form a11y.
Testing a Modal for Violations
A modal must expose role="dialog", set aria-modal="true", and carry an accessible name—typically via aria-labelledby pointing at its heading. Axe verifies the name reference resolves; you assert the dialog opens, is named correctly, and carries aria-modal.
// ConfirmDialog.tsx (abridged)
export function ConfirmDialog() {
const [open, setOpen] = useState(false);
return (
<>
<button onClick={() => setOpen(true)}>Delete project</button>
{open && (
<div role="dialog" aria-modal="true" aria-labelledby="dlg-title">
<h2 id="dlg-title">Delete project?</h2> {/* supplies the dialog name */}
<p>This action cannot be undone.</p>
<button onClick={() => setOpen(false)}>Cancel</button>
<button onClick={() => setOpen(false)}>Delete</button>
</div>
)}
</>
);
}
// ConfirmDialog.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { axe } from 'jest-axe';
import { ConfirmDialog } from './ConfirmDialog';
test('open dialog has no violations and a correct dialog contract', async () => {
const user = userEvent.setup();
const { container } = render(<ConfirmDialog />);
// Drive into the OPEN state—the dialog does not exist on initial render
await user.click(screen.getByRole('button', { name: /delete project/i }));
// findByRole waits for the dialog to mount before axe and assertions run
const dialog = await screen.findByRole('dialog', { name: /delete project\?/i });
// axe confirms aria-labelledby resolves and the structure is legal (1.3.1, 4.1.2)
expect(await axe(container)).toHaveNoViolations();
// Assert the contracts axe will not enforce: modal flag + accessible name
expect(dialog).toHaveAttribute('aria-modal', 'true');
expect(dialog).toHaveAccessibleName('Delete project?');
});
Querying getByRole('dialog', { name: ... }) does double duty: it fails if the dialog is missing and if its accessible name is wrong, so a single query guards both the role and the name from 4.1.2 Name, Role, Value. If your dialog renders through a React portal, axe scoped to container will skip it—handle that scope problem with the techniques in Debugging jest-axe Violations in CI.
How to Verify
Run the suite locally and confirm it fails for the right reasons before trusting it:
npx jest SignupForm ConfirmDialog --runInBand
Now sabotage each component deliberately—delete the <label htmlFor="email">, drop aria-modal, or point aria-labelledby at a missing id—and re-run. Each break must turn a test red with a readable message. A green suite after sabotage means your scope, your await, or your assertions are not actually exercising the contract.
Automated tests verify DOM state, not speech. Finish with a manual pass: open the form in a browser with NVDA (Windows) or VoiceOver (macOS), submit invalid data, and confirm the screen reader announces the error and reads it as the field's description when you focus the input. Open the modal and confirm it announces "Delete project?, dialog" and that focus is managed. The manual check catches what jsdom never can—real focus order, announcement timing, and contrast.
Common a11y Mistakes
- Unawaited
axe()— the assertion runs before the promise resolves, so the test passes having checked nothing. Alwaysawait axe(container). - Testing only the initial render — errors and dialogs appear after interaction. Drive the component into each state and run axe per state.
- Querying by
data-testidinstead of role/name — bypasses the accessibility tree, so naming regressions slip through. PrefergetByRole(..., { name }). - Asserting
aria-describedbyexists without checking it resolves — a dangling reference passes a presence check but breaks for AT. UsetoHaveAccessibleDescriptionto confirm the text. - Trusting jest-axe for contrast or focus visibility — jsdom has no layout; push those to an end-to-end browser run.
Conclusion
With the matcher registered once, each component test becomes a render, an await axe(container), and a small set of role/name assertions that pin down meaning. The form and modal here cover the two highest-risk patterns—error association and dialog semantics—and the same shape extends to menus, tabs, and toasts. Keep axe and semantic assertions paired, test every state, and confirm with a real screen reader.
Frequently Asked Questions
Do I need userEvent or can I use fireEvent?
Use @testing-library/user-event. It dispatches the full sequence of events a real user produces (focus, keydown, input, click), which more faithfully exercises the accessibility behavior—focus moving into a dialog, an input's invalid state updating—than the single synthetic event fireEvent fires.
Why assert toHaveAccessibleDescription when axe already ran?
Axe confirms the aria-describedby reference is structurally valid, but it does not know which message should describe the field. toHaveAccessibleDescription(/.../) verifies the field is actually described by the correct error text—the semantic contract behind 3.3.1 Error Identification.
My dialog test can't find the dialog with getByRole. What's wrong?
Either the dialog mounts asynchronously—use findByRole, which waits—or it renders into a portal outside the container. getByRole searches the whole document by default, so a portal is fine for the query; the scope problem affects axe(container), not Testing Library queries.