Accessibility CI/CD: Integrating jest-axe, cypress-axe, and pa11y
Getting accessibility testing into your CI/CD pipeline is the difference between accessibility that holds over time and accessibility that degrades with each release. Without pipeline integration, you rely on developers remembering to run checks manually — and that doesn't scale.
This guide covers the complete picture: jest-axe configuration and custom matchers, cypress-axe for E2E coverage, pa11y in CLI pipelines, GitHub Actions workflows, baseline snapshot strategies, acceptable violation configuration, and PR blocking.
jest-axe: Setup and Configuration
jest-axe wraps axe-core as a Jest custom matcher, giving you accessibility assertions directly alongside your unit and integration tests.
Installation and Global Setup
npm install --save-dev jest-axe// jest.setup.js
import { configureAxe, toHaveNoViolations } from 'jest-axe';
expect.extend(toHaveNoViolations);
// Configure default axe options applied to all tests
configureAxe({
rules: {
// Disable rules you've consciously decided are out of scope
// Document the reason inline
'color-contrast': { enabled: true },
'region': { enabled: false }, // Off by default — not all content needs landmarks
},
runOnly: {
type: 'tag',
values: ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']
}
});// jest.config.js
module.exports = {
setupFilesAfterFramework: ['<rootDir>/jest.setup.js'],
testEnvironment: 'jsdom'
};Custom Matchers
The built-in toHaveNoViolations fails on any violation. For projects with existing violations that need incremental remediation, custom matchers let you distinguish between violations you're actively fixing and new regressions:
// test-utils/a11y-matchers.js
import { axe } from 'jest-axe';
/**
* Custom matcher that fails only on impact levels above a threshold.
* Use during migration: block critical/serious, warn on moderate/minor.
*/
function toHaveNoViolationsAbove(received, impactLevel) {
const impactOrder = ['minor', 'moderate', 'serious', 'critical'];
const threshold = impactOrder.indexOf(impactLevel);
if (threshold === -1) {
throw new Error(`Invalid impact level: ${impactLevel}. Use minor|moderate|serious|critical`);
}
const blocking = received.violations.filter(v =>
impactOrder.indexOf(v.impact) >= threshold
);
if (blocking.length === 0) {
return { pass: true, message: () => 'No violations above threshold' };
}
const formatted = blocking.map(v => {
const nodes = v.nodes.map(n =>
` - ${n.target.join(', ')}\n ${n.failureSummary.split('\n')[0]}`
).join('\n');
return ` [${v.impact.toUpperCase()}] ${v.id}: ${v.help}\n${nodes}`;
}).join('\n\n');
return {
pass: false,
message: () =>
`Found ${blocking.length} accessibility violation(s) at or above "${impactLevel}" impact:\n\n${formatted}`
};
}
/**
* Custom matcher that checks for specific rules only.
* Use for targeted testing of known-risky areas.
*/
function toPassAccessibilityRules(received, ruleIds) {
const failing = received.violations.filter(v => ruleIds.includes(v.id));
if (failing.length === 0) {
return { pass: true, message: () => 'All specified rules pass' };
}
const formatted = failing.map(v =>
` [${v.id}] ${v.help} — ${v.nodes.length} element(s)`
).join('\n');
return {
pass: false,
message: () => `Failed accessibility rules:\n${formatted}`
};
}
export { toHaveNoViolationsAbove, toPassAccessibilityRules };// jest.setup.js
import { toHaveNoViolations } from 'jest-axe';
import { toHaveNoViolationsAbove, toPassAccessibilityRules } from './test-utils/a11y-matchers';
expect.extend({
toHaveNoViolations,
toHaveNoViolationsAbove,
toPassAccessibilityRules
});Usage in tests:
// Phase 1: block on critical/serious only (migration mode)
expect(results).toHaveNoViolationsAbove('serious');
// Phase 2: full WCAG AA compliance
expect(results).toHaveNoViolations();
// Targeted: specific rules only
expect(results).toPassAccessibilityRules(['color-contrast', 'label', 'button-name']);Testing with React Testing Library
// ProductCard.test.jsx
import React from 'react';
import { render } from '@testing-library/react';
import { axe } from 'jest-axe';
import { ProductCard } from './ProductCard';
const mockProduct = {
id: '1',
name: 'Wireless Headphones',
price: 79.99,
image: 'headphones.jpg',
rating: 4.5,
reviewCount: 234
};
describe('ProductCard accessibility', () => {
it('has no violations in default state', async () => {
const { container } = render(<ProductCard product={mockProduct} />);
expect(await axe(container)).toHaveNoViolations();
});
it('has no violations in loading state', async () => {
const { container } = render(<ProductCard loading />);
expect(await axe(container)).toHaveNoViolations();
});
it('has no violations when out of stock', async () => {
const { container } = render(
<ProductCard product={{ ...mockProduct, inStock: false }} />
);
expect(await axe(container)).toHaveNoViolations();
});
it('has no violations when in cart', async () => {
const { container } = render(
<ProductCard product={mockProduct} inCart />
);
expect(await axe(container)).toHaveNoViolations();
});
});cypress-axe: End-to-End Accessibility Testing
cypress-axe integrates axe-core into Cypress tests, giving you accessibility audits in a real browser against fully rendered application states — including authenticated sessions, dynamic content, and complex component compositions.
npm install --save-dev cypress-axe axe-core// cypress/support/e2e.js
import 'cypress-axe';Basic Usage
// cypress/e2e/accessibility.cy.js
describe('Accessibility', () => {
beforeEach(() => {
cy.injectAxe(); // Inject axe-core into the page
});
it('home page has no WCAG AA violations', () => {
cy.visit('/');
cy.checkA11y(null, {
runOnly: {
type: 'tag',
values: ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']
}
});
});
it('checkout flow has no violations', () => {
cy.visit('/cart');
cy.injectAxe();
cy.checkA11y();
cy.get('[data-testid="checkout-btn"]').click();
cy.injectAxe(); // Reinject after navigation
cy.checkA11y();
});
it('modal has no violations when open', () => {
cy.visit('/products');
cy.injectAxe();
cy.get('[data-testid="quick-view"]').first().click();
cy.get('[role="dialog"]').should('be.visible');
// Audit only the modal
cy.checkA11y('[role="dialog"]');
});
});Custom Violation Handler
By default, cy.checkA11y() throws a Cypress error with a generic message. A custom handler gives you structured, actionable output:
// cypress/support/a11y-helpers.js
export function logA11yViolations(violations) {
cy.task('log', `\n${violations.length} accessibility violation(s) found:\n`);
violations.forEach(({ id, impact, description, nodes }) => {
cy.task('log', `[${impact.toUpperCase()}] ${id}`);
cy.task('log', ` ${description}`);
nodes.forEach(({ target, html, failureSummary }) => {
cy.task('log', ` → ${target.join(', ')}`);
cy.task('log', ` HTML: ${html.substring(0, 100)}`);
cy.task('log', ` ${failureSummary.split('\n')[0]}`);
});
cy.task('log', '');
});
}
export function checkA11yWithReport(context, options = {}) {
cy.checkA11y(
context,
{
runOnly: {
type: 'tag',
values: ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']
},
...options
},
logA11yViolations,
false // Don't fail on violations — use custom assertion below
);
}Scoped Audits Per Component
// cypress/e2e/navigation.cy.js
import { checkA11yWithReport } from '../support/a11y-helpers';
describe('Navigation accessibility', () => {
it('main navigation has no violations', () => {
cy.visit('/');
cy.injectAxe();
checkA11yWithReport('header nav');
});
it('mobile nav has no violations when open', () => {
cy.viewport('iphone-x');
cy.visit('/');
cy.injectAxe();
cy.get('[data-testid="mobile-menu-toggle"]').click();
cy.get('[data-testid="mobile-nav"]').should('be.visible');
checkA11yWithReport('[data-testid="mobile-nav"]');
});
});Excluding Third-Party Content in Cypress
cy.checkA11y(
null,
{
exclude: [
['#intercom-frame'],
['[data-ad-container]'],
['.grecaptcha-badge']
]
}
);pa11y: CLI Integration for CI Pipelines
pa11y is a command-line accessibility testing tool that runs axe-core (and optionally HTML_CodeSniffer) against URLs. It's ideal for quick CI checks without a full test framework.
npm install --save-dev pa11y pa11y-ciCLI Usage
# Basic audit
npx pa11y https://example.com
<span class="hljs-comment"># WCAG AA standard, JSON output
npx pa11y \
--standard WCAG2AA \
--reporter json \
https://example.com
<span class="hljs-comment"># Ignore specific rules
npx pa11y \
--standard WCAG2AA \
--ignore <span class="hljs-string">"WCAG2AA.Principle1.Guideline1_4.1_4_3.G18.Fail" \
https://example.com
<span class="hljs-comment"># Threshold — fail only if violation count exceeds N
npx pa11y \
--threshold 5 \
https://example.compa11y-ci: Multi-URL Pipeline Testing
pa11y-ci tests multiple URLs and produces a pass/fail result based on configurable thresholds.
// .pa11yci.json
{
"defaults": {
"standard": "WCAG2AA",
"chromeLaunchConfig": {
"args": ["--no-sandbox", "--disable-setuid-sandbox"]
},
"timeout": 30000,
"wait": 2000,
"reporters": ["cli"],
"threshold": 0,
"ignore": [
"color-contrast"
]
},
"urls": [
"https://example.com/",
"https://example.com/about",
"https://example.com/products",
{
"url": "https://example.com/checkout",
"threshold": 2
}
]
}# Run pa11y-ci
npx pa11y-ci --config .pa11yci.jsonpa11y with Authentication
// pa11y-config.js
module.exports = {
defaults: {
standard: 'WCAG2AA',
actions: [
'navigate to https://example.com/login',
'set field #email to test@example.com',
'set field #password to testpassword',
'click element [type="submit"]',
'wait for url to be https://example.com/dashboard'
],
chromeLaunchConfig: {
args: ['--no-sandbox']
}
},
urls: [
'https://example.com/dashboard',
'https://example.com/settings',
'https://example.com/account'
]
};GitHub Actions Workflow
A complete CI workflow that runs all three tools:
# .github/workflows/accessibility.yml
name: Accessibility Tests
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
unit-accessibility:
name: Unit Tests (jest-axe)
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: Run jest-axe tests
run: npm run test:a11y
env:
CI: true
- name: Upload coverage report
if: always()
uses: actions/upload-artifact@v4
with:
name: jest-a11y-results
path: coverage/
e2e-accessibility:
name: E2E Tests (cypress-axe)
runs-on: ubuntu-latest
needs: unit-accessibility
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 application
run: npm run build
- name: Start application
run: npm run start:ci &
env:
PORT: 3000
- name: Wait for application
run: npx wait-on http://localhost:3000 --timeout 60000
- name: Run Cypress accessibility tests
uses: cypress-io/github-action@v6
with:
spec: 'cypress/e2e/accessibility.cy.js'
browser: chrome
headless: true
env:
CYPRESS_BASE_URL: http://localhost:3000
- name: Upload Cypress artifacts
if: failure()
uses: actions/upload-artifact@v4
with:
name: cypress-screenshots
path: cypress/screenshots/
pa11y-audit:
name: pa11y Audit
runs-on: ubuntu-latest
needs: e2e-accessibility
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: Run pa11y-ci
run: |
npx pa11y-ci \
--config .pa11yci.json \
--json > pa11y-results.json || true
# Parse results and fail if violations found
node -e "
const results = require('./pa11y-results.json');
const totalErrors = Object.values(results.results).flat()
.filter(r => r.type === 'error').length;
console.log('Total pa11y errors:', totalErrors);
if (totalErrors > 0) process.exit(1);
"
- name: Upload pa11y results
if: always()
uses: actions/upload-artifact@v4
with:
name: pa11y-results
path: pa11y-results.jsonPR Comment with Violation Summary
Add a step that posts violation details as a PR comment:
- name: Post accessibility report to PR
if: github.event_name == 'pull_request' && failure()
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
let report = '## Accessibility Test Failures\n\n';
try {
const pa11y = JSON.parse(fs.readFileSync('pa11y-results.json', 'utf8'));
const errors = Object.entries(pa11y.results).flatMap(([url, results]) =>
results
.filter(r => r.type === 'error')
.map(r => ({ url, ...r }))
);
if (errors.length > 0) {
report += `### pa11y: ${errors.length} error(s)\n\n`;
errors.slice(0, 10).forEach(e => {
report += `- **${e.url}**: ${e.message}\n`;
report += ` \`${e.selector || 'N/A'}\`\n\n`;
});
if (errors.length > 10) report += `_...and ${errors.length - 10} more_\n`;
}
} catch (e) {
report += '_pa11y results not available_\n';
}
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: report
});Baseline Snapshots to Prevent Regression
A baseline approach captures the current set of violations and fails only if new violations are introduced — not if existing ones remain. This is the right strategy for codebases with known violations you're working through.
Snapshot Strategy with axe-core
// scripts/capture-a11y-baseline.js
const { chromium } = require('playwright');
const AxeBuilder = require('@axe-core/playwright').default;
const fs = require('fs');
const path = require('path');
const PAGES = [
'/',
'/about',
'/products',
'/checkout'
];
const BASE_URL = process.env.BASE_URL || 'http://localhost:3000';
async function captureBaseline() {
const browser = await chromium.launch();
const page = await browser.newPage();
const baseline = {};
for (const pagePath of PAGES) {
const url = `${BASE_URL}${pagePath}`;
console.log(`Auditing ${url}...`);
await page.goto(url, { waitUntil: 'networkidle' });
const results = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'])
.analyze();
baseline[pagePath] = results.violations.map(v => ({
id: v.id,
impact: v.impact,
// Store selector targets to identify specific instances
nodes: v.nodes.map(n => n.target.join(', '))
}));
console.log(` ${results.violations.length} violation(s)`);
}
await browser.close();
const baselinePath = path.join(__dirname, '../.a11y-baseline.json');
fs.writeFileSync(baselinePath, JSON.stringify(baseline, null, 2));
console.log(`\nBaseline saved to ${baselinePath}`);
}
captureBaseline().catch(console.error);// scripts/check-a11y-regression.js
const { chromium } = require('playwright');
const AxeBuilder = require('@axe-core/playwright').default;
const fs = require('fs');
const path = require('path');
async function checkRegression() {
const baselinePath = path.join(__dirname, '../.a11y-baseline.json');
if (!fs.existsSync(baselinePath)) {
console.error('No baseline found. Run capture-a11y-baseline.js first.');
process.exit(1);
}
const baseline = JSON.parse(fs.readFileSync(baselinePath, 'utf8'));
const browser = await chromium.launch();
const page = await browser.newPage();
const BASE_URL = process.env.BASE_URL || 'http://localhost:3000';
let totalNew = 0;
let totalFixed = 0;
for (const [pagePath, baselineViolations] of Object.entries(baseline)) {
await page.goto(`${BASE_URL}${pagePath}`, { waitUntil: 'networkidle' });
const results = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'])
.analyze();
const currentIds = new Set(results.violations.map(v => v.id));
const baselineIds = new Set(baselineViolations.map(v => v.id));
const newViolations = [...currentIds].filter(id => !baselineIds.has(id));
const fixedViolations = [...baselineIds].filter(id => !currentIds.has(id));
if (newViolations.length > 0) {
console.error(`\n[NEW VIOLATIONS] ${pagePath}:`);
newViolations.forEach(id => {
const violation = results.violations.find(v => v.id === id);
console.error(` [${violation.impact.toUpperCase()}] ${id}: ${violation.help}`);
violation.nodes.forEach(n => console.error(` → ${n.target.join(', ')}`));
});
totalNew += newViolations.length;
}
if (fixedViolations.length > 0) {
console.log(`\n[FIXED] ${pagePath}: ${fixedViolations.join(', ')}`);
totalFixed += fixedViolations.length;
}
}
await browser.close();
console.log(`\nSummary: ${totalNew} new violations, ${totalFixed} fixed`);
if (totalNew > 0) {
console.error('\nBuild failed: new accessibility violations introduced.');
process.exit(1);
}
}
checkRegression().catch(console.error);Add to your CI workflow:
- name: Check accessibility regression
run: |
node scripts/check-a11y-regression.js
env:
BASE_URL: http://localhost:3000Configuring Acceptable Violations
For teams using an incremental approach, maintain a suppression registry that is code-reviewed and audited:
// a11y-suppressions.js
/**
* Accessibility violation suppressions.
* Each suppression must have:
* - rule: the axe rule ID
* - reason: why it's suppressed
* - ticket: tracking issue for resolution
* - expiry: date after which this suppression blocks CI (optional)
* - scope: CSS selector for the affected element(s), or 'global'
*/
module.exports = [
{
rule: 'color-contrast',
scope: '.legacy-widget',
reason: 'Third-party widget we cannot modify. Being replaced in Q3.',
ticket: 'JIRA-1234',
expiry: '2026-09-01'
},
{
rule: 'color-contrast',
scope: '.chart-tooltip',
reason: 'D3 generated tooltip — manual verification shows 5.1:1 on our backgrounds, axe samples incorrectly against gradient.',
ticket: 'JIRA-1256'
},
{
rule: 'region',
scope: 'global',
reason: 'Rule disabled globally — our layout uses explicit landmarks but not every content block is enclosed.',
ticket: 'JIRA-1300'
}
];Apply suppressions in CI:
const suppressions = require('./a11y-suppressions');
function buildAxeOptions() {
const today = new Date().toISOString().split('T')[0];
const rules = {};
suppressions
.filter(s => !s.expiry || s.expiry > today)
.filter(s => s.scope === 'global')
.forEach(s => {
rules[s.rule] = { enabled: false };
});
return { rules };
}
function buildExcludes() {
const today = new Date().toISOString().split('T')[0];
return suppressions
.filter(s => !s.expiry || s.expiry > today)
.filter(s => s.scope !== 'global')
.map(s => s.scope);
}
// In Playwright test
const options = buildAxeOptions();
const excludes = buildExcludes();
let builder = new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'])
.options(options);
excludes.forEach(selector => builder.exclude(selector));
const results = await builder.analyze();Blocking PRs on New Violations
The goal: main branch violations cannot increase. New violations introduced in a PR block merge.
# .github/workflows/accessibility-gate.yml
name: Accessibility Gate
on:
pull_request:
branches: [main]
jobs:
gate:
name: Accessibility Regression Gate
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # Need full history for baseline comparison
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Build PR branch
run: npm run build
- name: Start server
run: npm run start:ci &
env:
PORT: 3001
- name: Wait for server
run: npx wait-on http://localhost:3001
- name: Run accessibility regression check
id: a11y_check
run: |
node scripts/check-a11y-regression.js \
--baseline .a11y-baseline.json \
--url http://localhost:3001 \
--output pr-a11y-results.json
continue-on-error: true
- name: Post results to PR
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
const results = JSON.parse(
fs.readFileSync('pr-a11y-results.json', 'utf8')
);
if (results.newViolations.length === 0) {
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: '✅ **Accessibility Gate passed** — no new violations introduced.'
});
} else {
const summary = results.newViolations
.map(v => `- [\`${v.id}\`] **${v.impact}**: ${v.help} (${v.page})`)
.join('\n');
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: `❌ **Accessibility Gate failed** — ${results.newViolations.length} new violation(s) found:\n\n${summary}\n\nFix these before merging.`
});
}
- name: Fail if new violations
if: steps.a11y_check.outcome == 'failure'
run: |
echo "New accessibility violations found. See PR comment for details."
exit 1Accessibility Score Tracking Over Time
For production monitoring, track the Lighthouse accessibility score after each deploy:
// scripts/track-a11y-score.js
const lighthouse = require('lighthouse');
const chromeLauncher = require('chrome-launcher');
const fs = require('fs');
const path = require('path');
async function trackScore() {
const historyPath = path.join(__dirname, '../.a11y-score-history.json');
const history = fs.existsSync(historyPath)
? JSON.parse(fs.readFileSync(historyPath, 'utf8'))
: [];
const chrome = await chromeLauncher.launch({ chromeFlags: ['--headless'] });
const url = process.env.SITE_URL || 'https://example.com';
const lhr = await lighthouse(url, { port: chrome.port });
await chrome.kill();
const score = Math.round(lhr.lhr.categories.accessibility.score * 100);
const entry = {
date: new Date().toISOString(),
score,
commit: process.env.GITHUB_SHA?.slice(0, 8),
url
};
history.push(entry);
fs.writeFileSync(historyPath, JSON.stringify(history, null, 2));
console.log(`Accessibility score: ${score}/100`);
// Alert if score dropped more than 5 points from last recorded
if (history.length >= 2) {
const previous = history[history.length - 2].score;
const delta = score - previous;
if (delta < -5) {
console.error(`Score dropped ${Math.abs(delta)} points (${previous} → ${score})`);
process.exit(1);
}
}
}
trackScore().catch(console.error);Summary: The Three-Layer Strategy
The most robust accessibility CI/CD pipeline uses three complementary layers:
Layer 1: jest-axe in unit tests
- Catches violations at component level during development
- Fast (no browser launch)
- Runs on every file save in watch mode
- Cost of fix: minimal (code is right there)
Layer 2: cypress-axe in E2E tests
- Catches violations in real application states
- Covers component composition issues unit tests miss
- Verifies authenticated states, dynamic content, complex flows
- Cost of fix: moderate (requires understanding full page context)
Layer 3: pa11y-ci or Lighthouse against production
- Final gate before or after deploy
- Catches anything that slipped through previous layers
- Tracks score trend over time
- Cost of fix: high (production impact, coordinated deploy required)
The goal is to catch violations at Layer 1 — where they're cheapest to fix and closest to the code that caused them. Layers 2 and 3 exist as backstops, not primary mechanisms.
When violations reach Layer 3, that's a signal that your Layer 1 and 2 coverage needs to expand to include that class of component or interaction pattern. The pipeline becomes self-improving: every production violation becomes a gap to close in unit or integration tests.