Automated accessibility testing is done through axe-core integration with Testing Library or Playwright.
How it works: axe-core is a rules engine that scans the DOM for accessibility violations (missing alt text, contrast issues, ARIA errors, etc.). It integrates into your test suite so every component change is checked automatically.
With Vitest + Testing Library:
1npm install -D axe-core @axe-core/react @testing-library/react vitest
1// axe-utils.ts2import axe, { type AxeResults } from "axe-core";34export async function checkA11y(container: HTMLElement): Promise<AxeResults> {5 const results = await axe.run(container);67 const violations = results.violations;8 if (violations.length > 0) {9 const messages = violations.map(v => {10 const nodes = v.nodes11 .map(n => " - " + n.html + "\n Fix: " + n.failureSummary)12 .join("\n");13 return v.id + ": " + v.help + "\n" + nodes;14 }).join("\n\n");15 throw new Error("A11y violations:\n" + messages);16 }17 return results;18}1920// button.test.tsx21import { render, screen } from "@testing-library/react";22import userEvent from "@testing-library/user-event";23import { Button } from "./Button";24import { checkA11y } from "./axe-utils";2526describe("Button a11y", () => {27 it("has no violations in default state", async () => {28 const { container } = render(<Button>Button</Button>);29 await checkA11y(container);30 });3132 it("icon-only button has accessible name", async () => {33 const { container } = render(34 <Button aria-label="Close"><CloseIcon /></Button>35 );36 await checkA11y(container);37 });3839 it("disabled button is accessible", async () => {40 const { container } = render(41 <Button disabled>Disabled</Button>42 );43 await checkA11y(container);44 });4546 it("loading button announces busy state", async () => {47 const { container } = render(48 <Button isLoading>Loading...</Button>49 );50 const button = screen.getByRole("button");51 expect(button).toHaveAttribute("aria-busy", "true");52 await checkA11y(container);53 });54});
With Playwright (E2E):
1// tests/a11y.spec.ts2import { test, expect } from "@playwright/test";3import AxeBuilder from "@axe-core/playwright";45test("homepage is accessible", async ({ page }) => {6 await page.goto("/");7 const results = await new AxeBuilder({ page })8 .include("main")9 .disableRules(["color-contrast"]) // Exclude specific rules if needed10 .analyze();1112 expect(results.violations).toEqual([]);13});1415test("modal dialog is accessible", async ({ page }) => {16 await page.goto("/");17 await page.click("text=Open Modal");18 const results = await new AxeBuilder({ page })19 .include("[role=\"dialog\"]")20 .analyze();2122 expect(results.violations).toEqual([]);23});
What axe checks:
Configuration options:
.disableRules(["rule-id"])..include() and .exclude()..withTags(["wcag2a", "wcag2aa"]).Performance considerations:
Integration with CI: Add axe checks to your CI pipeline. Fail the build on any a11y violation to prevent regressions.