Integrating jest-axe into Form Component Unit Tests
Form components fail accessibility audits most often at the exact moment validation fires — when an error appears, a [aria-invalid] flips, or focus moves — yet those transient states rarely reach a browser-based audit; wiring jest-axe into your component unit tests lets you assert against the rendered DOM of each validity state in isolation, complementing full-page axe-core accessibility testing with fast, deterministic coverage.
When to Use This Component-Level axe Approach
This recipe runs axe-core against a single mounted component under jsdom, not against a live page. Reach for it when you want an accessibility assertion to live next to the component’s other unit tests and run on every save.
- Use component-level
jest-axewhen you need to audit a form fragment across states (pristine, invalid, submitted) that are hard to reproduce in a real browser. - Use full-page auditing via Automating axe-core Form Audits in CI when you need real layout, real CSS, and cross-component landmark checks.
- Use Testing Form Error Messages with Playwright when the concern is behaviour and copy (does the right message show?) rather than the accessibility tree.
The two tiers are complementary: unit-level jest-axe catches missing aria-describedby wiring in milliseconds; the CI page audit catches contrast and landmark issues jsdom cannot see.
Auditing an Invalid State with axe() and toHaveNoViolations()
The block below is a complete Vitest test file. It renders a form that follows the site baseline — <form novalidate> with validity driven by a single manual reportValidity() call on the Constraint Validation API — then audits both the pristine and the invalid DOM.
import { describe, it, expect } from "vitest";
import { axe, toHaveNoViolations } from "jest-axe";
expect.extend(toHaveNoViolations);
// Minimal component-under-test: a novalidate form whose error wiring
// is refreshed by one manual reportValidity() call on submit.
function mountSignupForm(): HTMLFormElement {
const form = document.createElement("form");
form.setAttribute("novalidate", ""); // suppress the native bubble UI
form.innerHTML = `
<label for="email">Email</label>
<input id="email" name="email" type="email" required
aria-describedby="email-error" />
<p id="email-error" role="alert" hidden></p>
<button type="submit">Create account</button>
`;
const input = form.querySelector<HTMLInputElement>("#email")!;
const error = form.querySelector<HTMLParagraphElement>("#email-error")!;
form.addEventListener("submit", (e) => {
e.preventDefault();
// Single manual pass: one checkValidity() decides the whole form.
if (!form.checkValidity()) {
input.setAttribute("aria-invalid", "true");
error.textContent = input.validationMessage;
error.hidden = false;
} else {
input.removeAttribute("aria-invalid");
error.hidden = true;
}
});
document.body.append(form);
return form;
}
describe("SignupForm accessibility", () => {
it("has no axe violations while pristine", async () => {
const form = mountSignupForm();
// axe walks the container's accessibility tree, not the whole page.
const results = await axe(form);
expect(results).toHaveNoViolations();
});
it("has no axe violations after a failed submit", async () => {
const form = mountSignupForm();
form.requestSubmit(); // fires the submit handler
// aria-invalid is now set and the alert is revealed; audit that DOM.
const results = await axe(form);
expect(results).toHaveNoViolations();
});
});
The key move is auditing after requestSubmit(): the second test sees aria-invalid="true" and the revealed role="alert", so axe verifies the association between the field and its message rather than an empty happy-path form.
axe() Configuration: Option Reference
axe(container, options) accepts the standard axe-core run options. These are the values that matter most when auditing a form fragment in jsdom.
| Option | Type | Default | Purpose |
|---|---|---|---|
runOnly |
{ type: "tag", values: string[] } |
all rules | Restrict to rule sets, e.g. ["wcag2a", "wcag22aa"], to match your compliance target. |
rules |
Record<string, { enabled: boolean }> |
all enabled | Toggle a single rule, e.g. disable color-contrast under jsdom where computed colour is unreliable. |
resultTypes |
string[] |
all types | Limit returned arrays (e.g. ["violations"]) to shrink the failure payload and speed up assertions. |
elementRef |
boolean |
false |
Include the live Node in each result node for custom reporting; leave off for serialisable output. |
reporter |
"v1" | "v2" | "raw" |
"v1" |
Shape of the results object; toHaveNoViolations() expects the default v2-compatible shape. |
configureAxe overrides |
object |
— | Passed once via configureAxe() from jest-axe to set defaults for every axe() call in a file. |
Disabling color-contrast is the most common jsdom adjustment — jsdom does not compute layout or resolved colours, so that rule produces noise. Keep it enabled in your real-browser CI audit instead.
Verification Steps
import { configureAxe } from "jest-axe";
// Shared config: silence layout-dependent rules that jsdom can't evaluate.
const axe = configureAxe({
rules: { "color-contrast": { enabled: false } },
});
it("wires aria-describedby to the visible error", async () => {
const form = mountSignupForm();
form.requestSubmit();
expect(await axe(form)).toHaveNoViolations();
});
Edge Cases & Failure Modes
Auditing before the state settles. If your component reveals errors through an async path — for example after asynchronous server checks resolve — calling axe() synchronously audits the pristine DOM and passes falsely. Fix it by awaiting the state change (await screen.findByRole("alert")) before awaiting axe().
role="alert" on a permanently rendered node. An always-present empty alert can make axe pass while screen readers announce nothing. Toggle the hidden attribute (as the baseline does) or mount the alert node only when there is a message, so the audited tree reflects real accessible error messaging.
False green from a stubbed reportValidity(). Some jsdom setups stub checkValidity() to always return true, so the invalid branch never runs and axe audits a valid form. Assert on the DOM (expect(input).toHaveAttribute("aria-invalid", "true")) before the axe call to guarantee you are auditing the failure state — the same discipline applies when verifying custom validity messages.
Parametrising One Audit Across Every ValidityState Flag
A single email field can fail for valueMissing, typeMismatch, or tooShort, and each flag drives a different validationMessage into the alert node. Rather than copy the render-then-audit dance for each one, drive them from a test.each table so every branch of the accessibility tree is covered by the same assertion. The value of this is not raw coverage — it is that a regression in any single error path (a message that stops populating, an aria-invalid that stops flipping) surfaces as a named, isolated failure instead of hiding inside one monolithic test.
import { describe, expect, test } from "vitest";
import { axe, toHaveNoViolations } from "jest-axe";
expect.extend(toHaveNoViolations);
// Each row is a distinct ValidityState the field can enter. The `set`
// callback puts the input into exactly that failing state before submit.
const cases: Array<{ flag: keyof ValidityState; value: string }> = [
{ flag: "valueMissing", value: "" },
{ flag: "typeMismatch", value: "not-an-email" },
{ flag: "tooShort", value: "a@b" },
];
describe("SignupForm error-state accessibility", () => {
test.each(cases)("stays accessible on $flag", async ({ flag, value }) => {
const form = mountSignupForm();
const input = form.querySelector<HTMLInputElement>("#email")!;
input.minLength = 6; // makes tooShort reachable
input.value = value;
form.requestSubmit();
// Prove we audited the *intended* branch, not a coincidental failure.
expect(input.validity[flag]).toBe(true);
expect(input).toHaveAttribute("aria-invalid", "true");
expect(await axe(form)).toHaveNoViolations();
});
});
The expect(input.validity[flag]).toBe(true) line is load-bearing: without it a typo in the seed value could push the field into a different invalid state that still passes axe, and the test name would lie about what it verified. Asserting the flag first pins each row to the exact ValidityState it claims to cover.
Reading the violations Array When an Audit Fails
toHaveNoViolations() prints a formatted report, but when you are debugging a flaky component test it helps to inspect the raw results object directly. Each entry in results.violations names the failed rule, its impact, and the specific nodes — which for form work is usually the exact input or label that lost its accessible name.
const results = await axe(form);
// Fail loudly with the offending selector, not just a rule id.
for (const violation of results.violations) {
console.error(
`${violation.id} (${violation.impact}): ${violation.help}\n` +
violation.nodes.map((n) => ` → ${n.target.join(" ")}`).join("\n"),
);
}
The impact field ("minor" through "critical") is worth reading before you spend time on a failure: a critical label violation means a field is unreachable by name for assistive tech, whereas a moderate finding may be tolerable while you refactor. The node.target array is a CSS selector path back to the element, so it tells you which of several inputs in the fragment regressed — indispensable once a form grows past a single field. Treat any serious or critical result as a hard gate; log-and-continue is only ever appropriate for minor findings you have consciously triaged.
Frequently Asked Questions
Does jest-axe replace running axe-core in a real browser?
No. Under jsdom there is no layout engine, so rules like color-contrast and anything geometry-dependent cannot run. Use jest-axe for fast structural checks (labels, aria-describedby, roles) at the component level, and keep a browser-based CI audit for contrast and landmark coverage.
Why does my test pass even though the form has no error message?
You are almost certainly auditing the pristine DOM. Call requestSubmit() and await the resulting alert before await axe(container), and assert aria-invalid="true" is present so the test proves it audited the invalid state.
How do I share axe configuration across every test file?
Call configureAxe() once and export the returned function from a test helper, or run expect.extend(toHaveNoViolations) in your Vitest setupFiles. Both keep rule toggles and the custom matcher consistent without repeating boilerplate per file.
Related Guides
- axe-core Accessibility Testing — the parent overview of every axe-core technique on this site.
- Automating axe-core Form Audits in CI — promote these component checks to a full-page gate in your pipeline.
- Testing Form Error Messages with Playwright — behavioural coverage of the copy and timing your axe tests take for granted.
- WCAG 3.3.1 Error Identification Checklist — the success criterion your invalid-state audit is enforcing.
- Focus management after a failed submit — pair the audited DOM with correct focus movement for a complete failure experience.