Accessibility Testing in CI
Wire axe and lint rules into CI without false confidence — what automation catches, what it misses, and a practical pipeline.
- 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
- New code / design system: fail on serious+critical.
- Legacy app: baseline existing violations, fail on new ones (axe sarif + diff, or dedicated tools).
- 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
- Running axe only on empty loading states.
- Excluding half the page with
excludeto silence noise. - Snapshotting full axe output as the only assertion.
- 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.
Related on this site
- Automated axe testing limits
- Testing Library
- Testing Library queries priority
- WCAG principles POUR
- End-to-end testing tradeoffs
Further reading
Related guides
- Component Testing StorybookUse Storybook as a component workshop and optional test runner — stories as living specs, interaction tests, and a11y checks.
- Contract Testing APIsKeep frontend and backend agreements honest — schema contracts, Pact-style consumer tests, and OpenAPI-driven checks without brittle E2E.
- Coverage Metrics PitfallsWhy 100% line coverage can still ship bugs — gaming metrics, useless tests, and what coverage is actually good for.
- End-to-End Testing TradeoffsWhen E2E tests earn their keep, why they flake, and how to keep a small critical-path suite instead of a slow second frontend.
- Fake Timers for DebounceTest debounced and throttled UI with Vitest/Jest fake timers — advance time deterministically without real sleeps.