Accessibility Testing & CI Integration: Catching A11y Issues Before They Ship
Automate Accessibility Audits with axe-core, Playwright, and GitHub Actions

You’ve built accessible components. Your forms have proper labels, your keyboard navigation works, and your contrast ratios pass. Now comes the question nobody wants to answer: how do you make sure it stays that way?
In part 1, we covered ARIA, keyboard navigation, and visual design fundamentals. In part 2, we tackled forms. This final part is about making accessibility a first-class citizen in your development workflow — not something you audit once a quarter and forget about.
importantThis article will focus on automated testing and CI integration. Automated tools catch around 30-50% of WCAG issues. Manual testing with real assistive technologies is still essential — but automation prevents the regressions that undo all your hard work.
Why Automate Accessibility Testing?
Here’s a pattern you might have seen: Does a big accessibility push, fixes hundreds of issues, ships it, and six months later..
Half those fixes have regressed. New features got added, someone replaced a semantic button with a styled div, placeholder-only labels crept back in.
Accessibility regressions happen because they’re invisible to most developers during regular testing. You don’t notice a missing aria-label the way you notice a broken layout. By the time someone flags it, the damage is spread across dozens of components.
Automated testing catches the low-hanging fruit - missing alt text, broken label associations, contrast failures, invalid ARIA attributes before they ever hit production. It’s not a replacement for manual testing, but it’s a safety net that prevents the most common mistakes from slipping through.
The Testing Pyramid for Accessibility
Think of a11y testing in layers, similar to the traditional testing pyramid:
Static analysis (fastest, cheapest): Linting rules that flag issues in your code before it even runs. Things like missing alt attributes, invalid ARIA roles, or form inputs without labels.
Component-level tests (fast, targeted): Unit and integration tests that render components and check their accessibility tree. This is where axe-core shines.
End-to-end tests (slower, comprehensive): Full browser tests that navigate your app like a real user and audit entire pages. Playwright and Cypress both support this well.
Manual audits (slowest, most thorough): Screen reader testing, keyboard walkthroughs, and user testing with people who rely on assistive technology. No automation replaces this.
The goal is to catch as much as possible in the lower layers so manual audits can focus on the nuanced stuff. Like whether your tab order actually makes sense, not just whether it technically works.
Static Analysis with eslint-plugin-jsx-a11y
If you’re writing React (or any JSX-based framework), this plugin catches accessibility issues at the linting stage before your code even compiles.
npm install --save-dev eslint-plugin-jsx-a11yAdd it to your ESLint config:
{
"extends": ["plugin:jsx-a11y/recommended"],
"plugins": ["jsx-a11y"]
}This catches things like:
<img>withoutalt- Click handlers on non-interactive elements without keyboard support
- Missing
htmlForon labels - Invalid ARIA attributes or roles
// ❌ ESLint will flag this
<div onClick={handleClick}>Click me</div>
// ✅ This passes
<button onClick={handleClick}>Click me</button>
// ❌ Missing alt
<img src="photo.jpg" />
// ✅ Descriptive alt
<img src="photo.jpg" alt="Mountain landscape at sunset" />tipIf you’re using TypeScript with strict mode (which you should be), pairing
eslint-plugin-jsx-a11ywith your type checking catches a surprising number of issues before you even open a browser.
The recommended preset is sensible for most projects. There’s also a strict preset that enforces additional rules, but I’d start with recommended and tighten from there based on what your team actually needs.
Component Testing with axe-core
axe-core is the engine behind most automated accessibility testing tools. It runs a set of rules against a DOM tree and reports violations, grouped by impact level (critical, serious, moderate, minor).
Setting Up with Jest and React Testing Library
npm install --save-dev jest-axe @axe-core/reactHere’s the basic pattern:
import { render } from '@testing-library/react';
import { axe, toHaveNoViolations } from 'jest-axe';
expect.extend(toHaveNoViolations);
describe('LoginForm', () => {
it('should have no accessibility violations', async () => {
const { container } = render(<LoginForm />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
});That’s it. One assertion that checks your rendered component against dozens of WCAG rules.
Making It More Useful
The basic setup catches violations, but the error messages can be dense. Here’s a helper that makes failures more actionable:
import { render, RenderResult } from '@testing-library/react';
import { axe, toHaveNoViolations } from 'jest-axe';
expect.extend(toHaveNoViolations);
async function checkA11y(ui: React.ReactElement) {
const { container } = render(ui);
const results = await axe(container);
expect(results).toHaveNoViolations();
}
// Usage in tests
it('navigation is accessible', async () => {
await checkA11y(<Navigation items={mockItems} />);
});
it('search bar is accessible', async () => {
await checkA11y(<SearchBar onSearch={jest.fn()} />);
});important
axe-coretests are fast. Usually under 100ms per component. There’s no good reason to skip them. Add one to every component test file and make it a team convention.
Testing Different States
Components often have multiple states, and each one needs to be accessible:
describe('Modal', () => {
it('is accessible when open', async () => {
const { container } = render(
<Modal isOpen={true} onClose={jest.fn()} title="Confirm Action">
<p>Are you sure?</p>
</Modal>
);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('manages focus correctly when opened', () => {
const { getByRole } = render(
<Modal isOpen={true} onClose={jest.fn()} title="Confirm Action">
<p>Are you sure?</p>
</Modal>
);
// Focus should move to the modal or its first focusable element
expect(getByRole('dialog')).toHaveFocus();
});
});describe('Accordion', () => {
it('is accessible when collapsed', async () => {
const { container } = render(
<Accordion title="Details" expanded={false}>
<p>Hidden content</p>
</Accordion>
);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('is accessible when expanded', async () => {
const { container } = render(
<Accordion title="Details" expanded={true}>
<p>Visible content</p>
</Accordion>
);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('uses correct ARIA attributes', () => {
const { getByRole } = render(
<Accordion title="Details" expanded={false}>
<p>Content</p>
</Accordion>
);
const button = getByRole('button', { name: 'Details' });
expect(button).toHaveAttribute('aria-expanded', 'false');
});
});End-to-End Testing with Playwright
Component tests are great, but they don’t test the full picture — routing, dynamic content loading, multi-step flows. That’s where Playwright comes in.
Setting Up Accessibility Audits
Playwright has built-in support for axe-core through the @axe-core/playwright package:
npm install --save-dev @axe-core/playwrightimport { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
test.describe('Homepage accessibility', () => {
test('should have no a11y violations', async ({ page }) => {
await page.goto('/');
const results = await new AxeBuilder({ page }).analyze();
expect(results.violations).toEqual([]);
});
});Targeting Specific WCAG Levels
You probably want to test against a specific conformance level rather than every rule:
test('meets WCAG 2.2 AA standards', async ({ page }) => {
await page.goto('/');
const results = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa', 'wcag22aa'])
.analyze();
expect(results.violations).toEqual([]);
});Testing Interactive Flows
Here’s where e2e accessibility testing gets really valuable. Testing flows that involve state changes, navigation, and dynamic content:
test('form submission flow is accessible', async ({ page }) => {
await page.goto('/contact');
// Audit the initial form state
let results = await new AxeBuilder({ page })
.include('#contact-form')
.analyze();
expect(results.violations).toEqual([]);
// Submit with empty fields to trigger validation
await page.getByRole('button', { name: 'Submit' }).click();
// Audit the error state
results = await new AxeBuilder({ page })
.include('#contact-form')
.analyze();
expect(results.violations).toEqual([]);
// Check that error messages are announced
const errorMessages = page.getByRole('alert');
await expect(errorMessages.first()).toBeVisible();
});Keyboard Navigation Tests
Playwright can simulate keyboard-only navigation, which is perfect for testing tab order and focus management:
test('main navigation is keyboard accessible', async ({ page }) => {
await page.goto('/');
// Tab to skip link
await page.keyboard.press('Tab');
const skipLink = page.getByText('Skip to main content');
await expect(skipLink).toBeFocused();
// Use skip link
await page.keyboard.press('Enter');
const main = page.getByRole('main');
await expect(main).toBeFocused();
// Tab through navigation items
await page.goto('/');
await page.keyboard.press('Tab'); // Skip link
await page.keyboard.press('Tab'); // First nav item
const firstNavLink = page.getByRole('navigation').getByRole('link').first();
await expect(firstNavLink).toBeFocused();
});test('modal traps focus correctly', async ({ page }) => {
await page.goto('/dashboard');
// Open modal
await page.getByRole('button', { name: 'Settings' }).click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
// Tab through all focusable elements in the modal
const focusableElements = dialog.locator(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
const count = await focusableElements.count();
for (let i = 0; i < count + 1; i++) {
await page.keyboard.press('Tab');
}
// Focus should wrap back to the first element, not escape the modal
const activeElement = page.locator(':focus');
await expect(dialog).toContainText(await activeElement.textContent() || '');
// Escape closes the modal
await page.keyboard.press('Escape');
await expect(dialog).not.toBeVisible();
});tipRun Playwright a11y tests against your staging environment as part of your deployment pipeline. Testing against a real deployment catches issues that local dev servers might miss; like missing alt text on CMS-managed images.
Setting Up Lighthouse CI
Lighthouse CI gives you performance and accessibility scores on every pull request. It’s a broad audit that complements the targeted testing we’ve set up with axe-core.
GitHub Actions Workflow
Here’s a workflow that runs Lighthouse CI on every PR:
name: Accessibility Audit
on:
pull_request:
branches: [main]
jobs:
a11y-audit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Build
run: npm run build
- name: Run Lighthouse CI
uses: treosh/lighthouse-ci-action@v12
with:
configPath: ./lighthouserc.json
uploadArtifacts: true
temporaryPublicStorage: trueLighthouse CI Configuration
Create a lighthouserc.json at your project root:
{
"ci": {
"collect": {
"staticDistDir": "./dist",
"url": [
"http://localhost/",
"http://localhost/articles/",
"http://localhost/contact/"
]
},
"assert": {
"assertions": {
"categories:accessibility": ["error", { "minScore": 0.9 }],
"color-contrast": "error",
"image-alt": "error",
"label": "error",
"link-name": "error",
"button-name": "error",
"html-has-lang": "error",
"meta-viewport": "error"
}
},
"upload": {
"target": "temporary-public-storage"
}
}
}This configuration fails the build if the accessibility score drops below 90% or if any critical a11y audit fails. Adjust the minScore threshold based on where your site currently stands.
Note: Start realistic and ratchet it up over time.
Integrating axe-core Directly in CI
For more granular control than Lighthouse provides, run axe-core audits directly in your CI pipeline:
name: A11y Tests
on:
pull_request:
branches: [main]
jobs:
accessibility:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Build
run: npm run build
- name: Start server
run: npx serve dist -l 3000 &
- name: Wait for server
run: npx wait-on http://localhost:3000
- name: Run Playwright a11y tests
run: npx playwright test --project=a11y
- name: Upload results
if: always()
uses: actions/upload-artifact@v4
with:
name: a11y-report
path: test-results/Playwright Config for A11y Tests
Separate your a11y tests into their own project so they can run independently:
// playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
projects: [
{
name: 'a11y',
testDir: './tests/a11y',
use: {
baseURL: 'http://localhost:3000',
},
},
// ... other test projects
],
});Handling Violations Gracefully
When you first add automated a11y testing to an existing project, you’ll probably get a wall of violations. That’s normal. Here’s how to deal with it without losing your mind.
Start with Critical and Serious Only
axe-core categorizes violations by impact: critical, serious, moderate, and minor. Start by failing your build only on the top two:
test('no critical or serious a11y violations', async ({ page }) => {
await page.goto('/');
const results = await new AxeBuilder({ page }).analyze();
const criticalAndSerious = results.violations.filter(
(v) => v.impact === 'critical' || v.impact === 'serious'
);
expect(criticalAndSerious).toEqual([]);
});Once those are clean, expand to moderate, then minor. Trying to fix everything at once leads to burnout and a stalled PR.
Excluding Known Issues Temporarily
If you have known issues that can’t be fixed immediately, exclude specific rules rather than disabling the entire test:
const results = await new AxeBuilder({ page })
.disableRules(['color-contrast']) // Known issue, tracked in JIRA-1234
.analyze();importantAlways leave a comment explaining why a rule is disabled and link to the tracking ticket. Disabled rules without context become permanent exceptions.
Creating an Accessibility Scorecard
Track your progress over time by logging violation counts:
// tests/a11y/audit-all-pages.spec.ts
import { test } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
import fs from 'fs';
const pages = ['/', '/articles/', '/about/', '/contact/'];
test('audit all pages and generate report', async ({ page }) => {
const report: Record<string, number> = {};
for (const url of pages) {
await page.goto(url);
const results = await new AxeBuilder({ page }).analyze();
report[url] = results.violations.length;
}
fs.writeFileSync(
'a11y-report.json',
JSON.stringify(report, null, 2)
);
// Log for CI visibility
console.table(report);
});SPA-Specific Accessibility Concerns
Single-page applications introduce accessibility challenges that static sites don’t have. If you’re building with React, Vue, or any client-side routing framework, watch out for these.
Route Change Announcements
When a user navigates in an SPA, the browser doesn’t do a full page load. Screen readers don’t automatically announce the new content. You need to handle this manually.
// A simple route announcer component
import { useEffect, useRef } from 'react';
import { useLocation } from 'react-router-dom';
function RouteAnnouncer() {
const location = useLocation();
const announcerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
// Set the announcement text to the page title
if (announcerRef.current) {
announcerRef.current.textContent =
document.title || 'Page loaded';
}
}, [location.pathname]);
return (
<div
ref={announcerRef}
role="status"
aria-live="polite"
aria-atomic="true"
style={{
position: 'absolute',
width: '1px',
height: '1px',
overflow: 'hidden',
clip: 'rect(0, 0, 0, 0)',
whiteSpace: 'nowrap',
}}
/>
);
}Focus Management on Navigation
After a route change, focus should move to a logical place—usually the main content area or the page heading:
import { useEffect, useRef } from 'react';
import { useLocation } from 'react-router-dom';
function useFocusOnRouteChange() {
const location = useLocation();
const mainRef = useRef<HTMLElement>(null);
useEffect(() => {
// Move focus to main content on route change
if (mainRef.current) {
mainRef.current.focus();
}
}, [location.pathname]);
return mainRef;
}
// In your layout
function Layout({ children }: { children: React.ReactNode }) {
const mainRef = useFocusOnRouteChange();
return (
<>
<nav>{/* navigation */}</nav>
<main ref={mainRef} tabIndex={-1}>
{children}
</main>
</>
);
}Testing SPA Accessibility
test('route changes are announced to screen readers', async ({ page }) => {
await page.goto('/');
// Navigate to a new page
await page.getByRole('link', { name: 'Articles' }).click();
// Check that the route announcer updated
const announcer = page.locator('[role="status"][aria-live="polite"]');
await expect(announcer).toHaveText(/articles/i);
// Check that focus moved appropriately
const main = page.getByRole('main');
await expect(main).toBeFocused();
});
test('dynamic content updates are announced', async ({ page }) => {
await page.goto('/search');
// Perform a search
await page.getByRole('searchbox').fill('accessibility');
await page.getByRole('button', { name: 'Search' }).click();
// Check that results are announced
const resultsRegion = page.locator('[aria-live="polite"]');
await expect(resultsRegion).toContainText(/results/i);
});Building Your Accessibility Testing Checklist
Here’s a practical checklist you can adapt for your CI pipeline. Not every project needs all of these, but it’s a solid starting point:
In your linter (every commit):
- All images have alt text
- Form inputs have associated labels
- Click handlers on non-interactive elements have keyboard alternatives
- ARIA attributes are valid
In component tests (every PR):
- Each component passes
axe()with no violations - Interactive components work with keyboard events
- Components in different states (loading, error, empty) are accessible
In e2e tests (every PR or nightly):
- Critical user flows are keyboard navigable
- Page-level audits pass WCAG 2.2 AA
- Focus management works on route changes
- Error states are announced to screen readers
In manual audits (quarterly or before major releases):
- Full screen reader walkthrough (VoiceOver, NVDA)
- Keyboard-only navigation of all flows
- Zoom to 200% without breaking layout
- Test with browser high contrast mode
The Bottom Line
Accessibility testing in CI isn’t about catching every single issue automatically—it’s about preventing regressions and building a culture where accessibility is part of the definition of “done.”
Start small. Add jest-axe to one component test. Set up a Lighthouse CI check on your main pages. Write one Playwright test that tabs through your nav. Each of these takes minutes to set up and saves hours of manual auditing down the road.
The tools are mature, the setup is straightforward, and the payoff is real. Your users—all of them—will benefit.
Now go automate your a11y pipeline. The internet will be a little more usable for it.
bonusQuick Reference:
- axe-core rules: The axe-core rule descriptions list every check the engine performs.
- WCAG Guidelines: The WCAG 2.2 Quick Reference for the full specification.
- Playwright a11y: The Playwright accessibility guide covers their built-in support.




