Accessibility testing in CI/CD catches regressions before they reach production. By automating checks on every pull request, you ensure that accessibility never degrades. This lesson shows you how to build a complete accessibility pipeline.
The Accessibility CI Strategy
Developer pushes code
│
▼
┌─────────────────┐
│ Linting (ESLint)│ ──> Catches JSX a11y violations in code
└────────┬────────┘
▼
┌─────────────────┐
│ Unit Tests │ ──> axe-core checks on rendered components
└────────┬────────┘
▼
┌─────────────────┐
│ Build │ ──> Compiles the application
└────────┬────────┘
▼
┌─────────────────┐
│ E2E A11y Tests │ ──> Playwright + axe on running app
└────────┬────────┘
▼
┌─────────────────┐
│ Lighthouse Audit│ ──> Accessibility score check
└────────┬────────┘
▼
┌─────────────────┐
│ Quality Gate │ ──> Block merge if score < threshold
└─────────────────┘Step 1: ESLint JSX Accessibility Plugin
The first line of defense catches issues at the code level.
# Install the plugin
pnpm add -D eslint-plugin-jsx-a11y// eslint.config.js
import jsxA11y from 'eslint-plugin-jsx-a11y';
export default [
{
plugins: {
'jsx-a11y': jsxA11y,
},
rules: {
// Require alt text on images
'jsx-a11y/alt-text': 'error',
// Require htmlFor on labels
'jsx-a11y/label-has-associated-control': 'error',
// No access key (inconsistent across browsers)
'jsx-a11y/no-access-key': 'error',
// Interactive elements must be focusable
'jsx-a11y/interactive-supports-focus': 'error',
// Click events need keyboard equivalents
'jsx-a11y/click-events-have-key-events': 'error',
'jsx-a11y/no-static-element-interactions': 'error',
// ARIA rules
'jsx-a11y/aria-props': 'error',
'jsx-a11y/aria-role': 'error',
'jsx-a11y/aria-unsupported-elements': 'error',
// Heading hierarchy
'jsx-a11y/heading-has-content': 'error',
// Anchor links must have content
'jsx-a11y/anchor-has-content': 'error',
'jsx-a11y/anchor-is-valid': 'error',
},
},
];Step 2: Component-Level A11y Tests
// tests/a11y/components.spec.ts
import { render } from '@testing-library/react';
import { axe, toHaveNoViolations } from 'jest-axe';
expect.extend(toHaveNoViolations);
// Test a navigation component
describe('Navigation accessibility', () => {
it('has proper landmark role', async () => {
const { container } = render(<Nav />);
const nav = container.querySelector('nav');
expect(nav).toHaveAttribute('aria-label');
const results = await axe(container);
expect(results).toHaveNoViolations();
});
});
// Test a form component
describe('Login form accessibility', () => {
it('labels all inputs', async () => {
const { container, getByLabelText } = render(<LoginForm />);
// Verify labels exist
expect(getByLabelText('Email')).toBeInTheDocument();
expect(getByLabelText('Password')).toBeInTheDocument();
// Run axe
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('announces errors to screen readers', async () => {
const { container, getByRole } = render(<LoginForm />);
// Submit empty form
const submitButton = getByRole('button', { name: 'Sign in' });
submitButton.click();
// Check that error messages have alert role
const alerts = container.querySelectorAll('[role="alert"]');
expect(alerts.length).toBeGreaterThan(0);
});
});Step 3: E2E Accessibility Tests with Playwright
// tests/a11y/e2e-suite.spec.ts
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
// Define all pages to test
const pagesToTest = [
{ url: '/', name: 'Homepage' },
{ url: '/courses', name: 'Courses' },
{ url: '/blog', name: 'Blog' },
];
// Generate tests for each page
for (const { url, name } of pagesToTest) {
test.describe(`${name} accessibility`, () => {
test('passes WCAG 2.2 AA checks', async ({ page }) => {
await page.goto(url);
const results = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa', 'wcag22aa'])
.analyze();
expect(results.violations).toEqual([]);
});
test('keyboard navigation works', async ({ page }) => {
await page.goto(url);
// Tab through the page and verify focus is visible
for (let i = 0; i < 10; i++) {
await page.keyboard.press('Tab');
const focusedElement = await page.evaluate(() => {
const el = document.activeElement;
if (!el || el === document.body) return null;
const style = window.getComputedStyle(el);
return {
tag: el.tagName,
outline: style.outline,
boxShadow: style.boxShadow,
text: el.textContent?.trim().slice(0, 50),
};
});
// Verify something is focused (not just body)
if (focusedElement) {
expect(
focusedElement.outline !== 'none' ||
focusedElement.boxShadow !== 'none'
).toBeTruthy();
}
}
});
test('skip link works', async ({ page }) => {
await page.goto(url);
// Press Tab to focus skip link
await page.keyboard.press('Tab');
const skipLink = page.locator('a[href="#main-content"]').first();
if (await skipLink.isVisible()) {
await skipLink.click();
// Verify focus moved to main content
const focusedId = await page.evaluate(
() => document.activeElement?.id
);
expect(focusedId).toBe('main-content');
}
});
});
}
// Test dynamic interactions
test('course filter maintains accessibility', async ({ page }) => {
await page.goto('/courses');
// Apply a filter
await page.click('text=Web Dev');
// Wait for results to update
await page.waitForTimeout(500);
// Re-scan after interaction
const results = await new AxeBuilder({ page })
.include('[data-testid="course-list"]')
.withTags(['wcag2a', 'wcag2aa'])
.analyze();
expect(results.violations).toEqual([]);
// Verify filter result is announced
const status = page.locator('[role="status"]');
await expect(status).toBeVisible();
});Step 4: Complete CI Pipeline
# .github/workflows/accessibility.yml
name: Accessibility CI
on:
pull_request:
branches: [main]
jobs:
lint:
name: A11y Lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- run: pnpm eslint --ext .tsx,.ts app/ --rule 'jsx-a11y/alt-text: error'
unit-a11y:
name: Component A11y Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- run: pnpm test -- --testPathPattern=a11y
e2e-a11y:
name: E2E A11y Tests
runs-on: ubuntu-latest
needs: [lint, unit-a11y]
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- run: npx playwright install --with-deps chromium
- name: Build application
run: pnpm build
- name: Start server
run: pnpm start &
- name: Wait for server
run: npx wait-on http://localhost:3000 --timeout 30000
- name: Run E2E accessibility tests
run: npx playwright test tests/a11y/
- name: Upload results
uses: actions/upload-artifact@v4
if: always()
with:
name: a11y-test-results
path: test-results/
lighthouse:
name: Lighthouse A11y Score
runs-on: ubuntu-latest
needs: [lint, unit-a11y]
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- run: pnpm build
- run: pnpm start &
- run: npx wait-on http://localhost:3000
- name: Lighthouse audit
run: |
npx lighthouse http://localhost:3000 \
--only-categories=accessibility \
--chrome-flags="--headless --no-sandbox" \
--output=json \
--output-path=./lighthouse.json
- name: Check accessibility score
run: |
SCORE=$(node -e "const r=require('./lighthouse.json'); console.log(r.categories.accessibility.score)")
echo "Accessibility score: $SCORE"
if (( $(echo "$SCORE < 0.9" | bc -l) )); then
echo "FAIL: Score $SCORE is below 0.9 threshold"
exit 1
fi
echo "PASS: Score meets threshold"
quality-gate:
name: A11y Quality Gate
runs-on: ubuntu-latest
needs: [e2e-a11y, lighthouse]
steps:
- name: All accessibility checks passed
run: echo "Accessibility quality gate passed. Safe to merge."Tracking Accessibility Over Time
// scripts/a11y-report.ts
// Generate a trend report from CI results
interface A11yResult {
date: string;
page: string;
violations: number;
score: number;
}
function generateTrendReport(results: A11yResult[]) {
const byPage = new Map<string, A11yResult[]>();
for (const result of results) {
const existing = byPage.get(result.page) || [];
existing.push(result);
byPage.set(result.page, existing);
}
console.log('Accessibility Trend Report');
console.log('='.repeat(60));
for (const [page, pageResults] of byPage) {
const latest = pageResults[pageResults.length - 1];
const previous = pageResults[pageResults.length - 2];
const trend = previous
? latest.violations < previous.violations
? 'IMPROVING'
: latest.violations > previous.violations
? 'DEGRADING'
: 'STABLE'
: 'NEW';
console.log(`\n${page}`);
console.log(` Violations: ${latest.violations} (${trend})`);
console.log(` Score: ${(latest.score * 100).toFixed(0)}%`);
}
}
Key Takeaways
- ESLint catches a11y issues at code time, before tests even run
- axe-core in unit tests verifies individual component accessibility
- Playwright + axe-core tests the running application end-to-end
- Lighthouse provides an accessibility score for quality gates
- A quality gate in CI prevents accessibility regressions from merging
- Track accessibility scores over time to measure improvement
- Automated CI checks are the foundation, but always supplement with manual screen reader testing
You have completed the Accessibility Testing course. You now have the skills to build accessible applications and verify they work for all users through both manual and automated testing.