ESC

Type to search the knowledge base.

Accessibility Testing in CI

Wire axe and lint rules into CI without false confidence — what automation catches, what it misses, and a practical pipeline.

intermediate3 min read
  • testing
  • accessibility-testing

Accessibility regressions ship when reviews only check visuals. CI a11y checks catch a useful subset automatically: missing names, bad roles, contrast on computed styles (sometimes), duplicate IDs. They do not replace keyboard or screen-reader testing.

Docs: axe-core, jest-axe, Testing Library, WCAG.

Layers that belong in CI

Layer Tool examples Catches
Lint eslint-plugin-jsx-a11y Obvious JSX anti-patterns
Unit/component jest-axe / vitest-axe, Testing Library roles Broken names, some ARIA
E2E Playwright + @axe-core/playwright Full-page issues on key routes
Optional Storybook a11y addon Component catalog drift

See automated axe testing limits.

Component test pattern

import { render } from '@testing-library/react';
import { axe } from 'vitest-axe';
import { LoginForm } from './LoginForm';

test('login form has no serious axe violations', async () => {
  const { container } = render(<LoginForm />);
  const results = await axe(container);
  expect(results).toHaveNoViolations();
});

Assert on behavior too — axe green + unusable keyboard flow still fails users:

test('can submit with keyboard', async () => {
  const user = userEvent.setup();
  render(<LoginForm onSubmit={onSubmit} />);
  await user.tab();
  await user.keyboard('a@b.com');
  // …
});

Playwright + axe on critical journeys

import AxeBuilder from '@axe-core/playwright';
import { test, expect } from '@playwright/test';

test('checkout has no critical a11y issues', async ({ page }) => {
  await page.goto('/checkout');
  const results = await new AxeBuilder({ page })
    .withTags(['wcag2a', 'wcag2aa', 'wcag21aa'])
    .analyze();

  const serious = results.violations.filter((v) =>
    ['serious', 'critical'].includes(v.impact ?? ''),
  );
  expect(serious).toEqual([]);
});

Start with critical routes: login, signup, checkout, core dashboard.

jsx-a11y in ESLint

// eslint.config — enable recommended jsx-a11y rules

Fails PR on <img> without alt, click handlers on non-interactive elements without keyboard support (rule-dependent), etc. Cheap signal.

Policy: fail build vs warn

  1. New code / design system: fail on serious+critical.
  2. Legacy app: baseline existing violations, fail on new ones (axe sarif + diff, or dedicated tools).
  3. Never “warn forever” without an owner.

What CI will not catch

  • Focus order / trap bugs
  • Meaningful alt text quality
  • Color-only meaning in charts
  • Screen reader verbosity
  • Timing and motion preferences in real AT

Pair with manual checklists — keyboard accessibility checklist, screen reader testing basics.

Footguns

  1. Running axe only on empty loading states.
  2. Excluding half the page with exclude to silence noise.
  3. Snapshotting full axe output as the only assertion.
  4. No color-contrast context (themes, dark mode untested).

Interview out-loud answer

“I put jsx-a11y, component-level axe, and axe on critical Playwright flows in CI. Automation catches a fraction of WCAG issues; keyboard and SR testing stay manual. I fail on serious violations for new UI and baseline legacy debt.”

Baseline management

Store a JSON baseline of known violations for legacy routes and fail when the set grows. Require tickets for each baseline entry. Shrinking the baseline should be celebrated in release notes — same as reducing SEV bugs.

Extra practice

Write a minimal demo in a scratch file or the playground: one happy path, one failure path, and one boundary input. If you cannot exhibit a bug that the pattern prevents, you do not own the concept yet — re-read the primary docs linked below and tighten the example until the failure is obvious.

Further reading

Related guides