axe-core Integration
The Industry Standard
axe-core (by Deque Systems) is the most widely used accessibility testing engine, available as a library, browser extension, and CI tool. It implements over 100 rules covering WCAG 2.0, 2.1, and 2.2 at both A and AA levels.
Playwright + axe-core
// tests/accessibility/axe-audit.spec.ts
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
test.describe('Accessibility Audit', () => {
test('homepage has no critical accessibility violations', async ({ page }) => {
await page.goto('/');
const results = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa', 'wcag21aa']) // WCAG 2.1 AA
.exclude('#third-party-widget') // Exclude content you don't control
.analyze();
// Report all violations with details
if (results.violations.length > 0) {
const report = results.violations.map(v => ({
rule: v.id,
impact: v.impact,
description: v.description,
helpUrl: v.helpUrl,
nodes: v.nodes.length,
elements: v.nodes.map(n => n.html).slice(0, 3),
}));
console.log('Accessibility violations:', JSON.stringify(report, null, 2));
}
expect(results.violations).toEqual([]);
});
test('form page meets WCAG 2.1 AA', async ({ page }) => {
await page.goto('/checkout');
const results = await new AxeBuilder({ page })
.withTags(['wcag2aa'])
.analyze();
// Separate critical from minor violations
const critical = results.violations.filter(v =>
v.impact === 'critical' || v.impact === 'serious'
);
const minor = results.violations.filter(v =>
v.impact === 'moderate' || v.impact === 'minor'
);
// Critical violations fail the build
expect(critical).toEqual([]);
// Minor violations are logged as warnings
if (minor.length > 0) {
console.warn(`${minor.length} minor accessibility issues found`);
minor.forEach(v => console.warn(` - ${v.id}: ${v.description}`));
}
});
test('all pages pass accessibility audit', async ({ page }) => {
const pages = [
'/', '/login', '/register', '/products',
'/products/1', '/cart', '/checkout', '/account',
'/help', '/about', '/privacy', '/terms'
];
const allViolations: Record<string, any[]> = {};
for (const pagePath of pages) {
await page.goto(pagePath);
const results = await new AxeBuilder({ page })
.withTags(['wcag2aa'])
.analyze();
if (results.violations.length > 0) {
allViolations[pagePath] = results.violations;
}
}
const totalViolations = Object.values(allViolations)
.reduce((sum, v) => sum + v.length, 0);
if (totalViolations > 0) {
console.error('Accessibility violations by page:');
for (const [pagePath, violations] of Object.entries(allViolations)) {
console.error(`\n${pagePath}:`);
violations.forEach(v =>
console.error(` [${v.impact}] ${v.id}: ${v.description} (${v.nodes.length} elements)`)
);
}
}
expect(totalViolations).toBe(0);
});
});
Accessibility Trees via Playwright
Playwright can access the browser's accessibility tree, which represents what assistive technologies actually see:
// tests/accessibility/tree-inspection.spec.ts
import { test, expect } from '@playwright/test';
test('navigation has correct accessible structure', async ({ page }) => {
await page.goto('/');
// Get the full accessibility tree snapshot
const snapshot = await page.accessibility.snapshot();
// Find the navigation landmark
const nav = findNode(snapshot, { role: 'navigation' });
expect(nav).not.toBeNull();
expect(nav.name).toBe('Main navigation');
// Verify all nav links are present and accessible
const links = nav.children.filter((child: any) => child.role === 'link');
const linkNames = links.map((l: any) => l.name);
expect(linkNames).toContain('Home');
expect(linkNames).toContain('Products');
expect(linkNames).toContain('Cart');
expect(linkNames).toContain('Account');
});
test('form has proper label associations', async ({ page }) => {
await page.goto('/register');
const snapshot = await page.accessibility.snapshot();
// Find all text inputs in the accessibility tree
const inputs = findAllNodes(snapshot, { role: 'textbox' });
for (const input of inputs) {
// Every text input must have a name (label)
expect(input.name).toBeTruthy();
expect(input.name.length).toBeGreaterThan(0);
// Name should not be a placeholder (common mistake)
expect(input.name).not.toMatch(/enter|type here/i);
}
});
function findNode(root: any, criteria: any): any {
if (Object.entries(criteria).every(([k, v]) => root[k] === v)) return root;
for (const child of root.children || []) {
const found = findNode(child, criteria);
if (found) return found;
}
return null;
}
function findAllNodes(root: any, criteria: any): any[] {
const results: any[] = [];
if (Object.entries(criteria).every(([k, v]) => root[k] === v)) results.push(root);
for (const child of root.children || []) {
results.push(...findAllNodes(child, criteria));
}
return results;
}
Testing Interactive States
Accessibility issues often appear in interactive states that axe-core's default scan misses:
test('modal accessibility after opening', async ({ page }) => {
await page.goto('/');
// axe scan of the page BEFORE modal
const beforeResults = await new AxeBuilder({ page }).analyze();
expect(beforeResults.violations).toEqual([]);
// Open modal
await page.click('[data-testid="open-modal"]');
await page.waitForSelector('[role="dialog"]');
// axe scan AFTER modal opens -- catches issues in modal content
const afterResults = await new AxeBuilder({ page })
.include('[role="dialog"]')
.analyze();
expect(afterResults.violations).toEqual([]);
});
test('form error state accessibility', async ({ page }) => {
await page.goto('/register');
// Submit empty form to trigger validation
await page.click('[data-testid="submit-btn"]');
// Scan the page in its error state
const results = await new AxeBuilder({ page }).analyze();
expect(results.violations).toEqual([]);
// Verify error messages are linked to inputs via aria-describedby
const errorInputs = await page.locator('[aria-invalid="true"]').all();
for (const input of errorInputs) {
const describedby = await input.getAttribute('aria-describedby');
expect(describedby).toBeTruthy();
// Verify the referenced error message exists and is visible
const errorMsg = page.locator(`#${describedby}`);
await expect(errorMsg).toBeVisible();
}
});
Generating Accessibility Reports
// tests/accessibility/report-generator.ts
import AxeBuilder from '@axe-core/playwright';
interface A11yReport {
url: string;
violations: number;
critical: number;
serious: number;
moderate: number;
minor: number;
details: any[];
}
async function generateReport(page, url: string): Promise<A11yReport> {
await page.goto(url);
const results = await new AxeBuilder({ page })
.withTags(['wcag2aa'])
.analyze();
return {
url,
violations: results.violations.length,
critical: results.violations.filter(v => v.impact === 'critical').length,
serious: results.violations.filter(v => v.impact === 'serious').length,
moderate: results.violations.filter(v => v.impact === 'moderate').length,
minor: results.violations.filter(v => v.impact === 'minor').length,
details: results.violations.map(v => ({
id: v.id,
impact: v.impact,
description: v.description,
helpUrl: v.helpUrl,
affectedElements: v.nodes.length,
})),
};
}
axe-core is the foundation of any accessibility testing strategy. It runs in milliseconds, catches the most common violations, and integrates with every major test framework. Run it on every page on every PR as a non-negotiable quality gate.