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-axe when 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.

jest-axe component test flow Render the component, trigger a validation state, then assert the DOM has no axe violations. One assertion per validity state, run under jsdom render() mount the form fragment reportValidity() drive the invalid state await axe(container) expect no violations
Each test renders the component, forces one validity state, and audits the exact DOM that state produces.

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.

← Back to axe-core Accessibility Testing