axe-core + Playwright: Automated Accessibility Testing in CI
Accessibility bugs are cheap to fix when caught during development and expensive to fix in production. Automated accessibility testing with axe-core and Playwright catches a significant portion of WCAG violations without manual effort — and it fits neatly into any CI pipeline. This guide walks through everything from installation to handling known violations in a mature test suite.
Why axe-core?
axe-core is the engine behind most serious accessibility testing tools. It powers browser extensions (axe DevTools), integrations for Cypress, WebdriverIO, Selenium, and the @axe-core/playwright package. Deque Systems maintains it, and it's used by organizations like Microsoft, Google, and the US government.
What makes axe-core credible:
- Zero false positives by design. The team deliberately keeps rules conservative — if axe flags something, it's a real violation.
- Covers roughly 57% of WCAG 2.1 success criteria automatically.
- Rules are individually documented with impact levels (critical, serious, moderate, minor) and links to fixes.
The remaining ~43% of WCAG issues require human judgment (color contrast ratios, cognitive load, screen reader flow) — but catching the automatable 57% in CI is a substantial baseline.
Installation
Start with a working Playwright setup. If you don't have one yet:
npm init playwright@latestThen install the axe-core Playwright integration:
npm install --save-dev @axe-core/playwrightThat's it. No additional browser setup needed — @axe-core/playwright injects axe-core into the page and runs analysis in the browser context.
Basic Page Scan
Here's the minimal setup to scan a full page:
// tests/accessibility.spec.ts
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
test('homepage should have no accessibility violations', async ({ page }) => {
await page.goto('/');
const accessibilityScanResults = await new AxeBuilder({ page }).analyze();
expect(accessibilityScanResults.violations).toEqual([]);
});When this test fails, the violations array tells you exactly what's wrong. Each violation object has:
{
id: 'color-contrast',
impact: 'serious',
description: 'Ensure the contrast between foreground and background colors meets WCAG 2 AA contrast ratio thresholds',
nodes: [
{
html: '<span class="muted-text">Secondary info</span>',
failureSummary: 'Fix any of the following: Element has insufficient color contrast...',
target: ['.muted-text']
}
]
}Scanning Specific Components
Full-page scans are useful but noisy during early development. You can scope the scan to a specific DOM element using include:
test('navigation menu should be accessible', async ({ page }) => {
await page.goto('/');
const results = await new AxeBuilder({ page })
.include('nav[aria-label="Main navigation"]')
.analyze();
expect(results.violations).toEqual([]);
});
test('login form should be accessible', async ({ page }) => {
await page.goto('/login');
const results = await new AxeBuilder({ page })
.include('#login-form')
.analyze();
expect(results.violations).toEqual([]);
});Conversely, use exclude to skip third-party widgets or components you don't own:
test('page content should be accessible (excluding chat widget)', async ({ page }) => {
await page.goto('/');
const results = await new AxeBuilder({ page })
.exclude('#intercom-container')
.exclude('.cookie-consent-banner')
.analyze();
expect(results.violations).toEqual([]);
});Testing Interactive States
Static page scans miss violations that only appear in dynamic states — open modals, expanded dropdowns, error messages. Test those explicitly:
test('modal dialog should be accessible when open', async ({ page }) => {
await page.goto('/dashboard');
// Open the modal
await page.click('[data-testid="open-settings-modal"]');
await page.waitForSelector('[role="dialog"]', { state: 'visible' });
const results = await new AxeBuilder({ page })
.include('[role="dialog"]')
.analyze();
expect(results.violations).toEqual([]);
});
test('form error states should be accessible', async ({ page }) => {
await page.goto('/signup');
// Trigger validation errors
await page.click('[type="submit"]');
await page.waitForSelector('[aria-invalid="true"]');
const results = await new AxeBuilder({ page }).analyze();
expect(results.violations).toEqual([]);
});
test('dropdown menu should be accessible when open', async ({ page }) => {
await page.goto('/');
await page.click('[aria-haspopup="true"]');
await page.waitForSelector('[role="menu"]', { state: 'visible' });
const results = await new AxeBuilder({ page })
.include('[role="menu"]')
.analyze();
expect(results.violations).toEqual([]);
});Configuring Rules
axe-core has over 100 rules. You can configure which ones run:
// Run only WCAG 2.1 AA rules
const results = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'])
.analyze();
// Disable specific rules you're intentionally deferring
const results = await new AxeBuilder({ page })
.disableRules(['color-contrast'])
.analyze();
// Run only specific rules
const results = await new AxeBuilder({ page })
.withRules(['aria-required-attr', 'label', 'heading-order'])
.analyze();Available tag groups:
| Tag | Covers |
|---|---|
wcag2a |
WCAG 2.0 Level A |
wcag2aa |
WCAG 2.0 Level AA |
wcag21a |
WCAG 2.1 Level A additions |
wcag21aa |
WCAG 2.1 Level AA additions |
best-practice |
Deque best practices (not required by WCAG) |
section508 |
US Section 508 requirements |
Handling Known Violations
In real projects, you'll encounter violations you can't fix immediately — a third-party component, a design constraint, or a backlog item. Don't skip the test entirely. Instead, track known violations explicitly so new violations are still caught:
test('dashboard page accessibility (with known violations)', async ({ page }) => {
await page.goto('/dashboard');
const results = await new AxeBuilder({ page }).analyze();
// Filter out known, tracked violations
const knownViolationIds = [
'color-contrast', // Tracked in issue #247 — design system update in Q2
];
const unexpectedViolations = results.violations.filter(
(violation) => !knownViolationIds.includes(violation.id)
);
expect(unexpectedViolations).toEqual([]);
});A cleaner approach is a shared helper:
// tests/helpers/accessibility.ts
export function filterKnownViolations(
violations: axe.Result[],
knownViolations: { id: string; reason: string; issue?: string }[]
) {
const knownIds = knownViolations.map((v) => v.id);
return violations.filter((v) => !knownIds.includes(v.id));
}import { filterKnownViolations } from './helpers/accessibility';
test('dashboard should have no unexpected accessibility violations', async ({ page }) => {
await page.goto('/dashboard');
const results = await new AxeBuilder({ page }).analyze();
const unexpected = filterKnownViolations(results.violations, [
{ id: 'color-contrast', reason: 'Design system update in Q2', issue: '#247' },
]);
expect(unexpected).toEqual([]);
});This approach keeps the test strict about new violations while documenting the existing debt.
Interpreting Violations
When a test fails, the output isn't always obvious. Write a custom reporter or use Playwright's built-in verbose output. Here's a helper that formats violations readably:
function formatViolations(violations: axe.Result[]): string {
return violations
.map((violation) => {
const nodes = violation.nodes
.map((node) => ` - ${node.target.join(', ')}\n ${node.failureSummary}`)
.join('\n');
return `[${violation.impact?.toUpperCase()}] ${violation.id}: ${violation.description}\nAffected elements:\n${nodes}`;
})
.join('\n\n');
}
test('homepage accessibility', async ({ page }) => {
await page.goto('/');
const results = await new AxeBuilder({ page }).analyze();
if (results.violations.length > 0) {
console.log('Accessibility violations found:\n' + formatViolations(results.violations));
}
expect(results.violations).toEqual([]);
});Common WCAG Issues axe-core Finds
1. Missing form labels (WCAG 1.3.1)
<!-- Violation -->
<input type="email" placeholder="Email address" />
<!-- Fix -->
<label for="email">Email address</label>
<input type="email" id="email" placeholder="email@example.com" />
<!-- Or with aria-label -->
<input type="email" aria-label="Email address" placeholder="email@example.com" />2. Insufficient color contrast (WCAG 1.4.3)
WCAG AA requires 4.5:1 ratio for normal text, 3:1 for large text. axe-core checks this automatically.
/* Violation: gray text on white (#767676 = 4.48:1 — barely fails) */
color: #767676;
/* Fix: darker gray (#595959 = 7.0:1) */
color: #595959;3. Images without alt text (WCAG 1.1.1)
<!-- Violation -->
<img src="chart.png" />
<!-- Fix: descriptive alt for meaningful images -->
<img src="chart.png" alt="Bar chart showing 40% increase in monthly active users" />
<!-- Fix: empty alt for decorative images -->
<img src="decorative-divider.png" alt="" />4. Missing skip link (WCAG 2.4.1)
<!-- Add at the top of body -->
<a href="#main-content" class="skip-link">Skip to main content</a>
<!-- CSS to show on focus -->
<style>
.skip-link {
position: absolute;
left: -9999px;
}
.skip-link:focus {
left: 0;
top: 0;
z-index: 9999;
}
</style>
<main id="main-content">
<!-- page content -->
</main>5. Incorrect heading hierarchy (WCAG 1.3.1)
<!-- Violation: skipping from h1 to h3 -->
<h1>Page Title</h1>
<h3>Section</h3>
<!-- Fix: sequential heading levels -->
<h1>Page Title</h1>
<h2>Section</h2>
<h3>Subsection</h3>Integrating into CI
GitHub Actions
# .github/workflows/accessibility.yml
name: Accessibility Tests
on:
push:
branches: [main, develop]
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: Install Playwright browsers
run: npx playwright install --with-deps chromium
- name: Start application
run: npm run start &
env:
NODE_ENV: test
- name: Wait for application
run: npx wait-on http://localhost:3000 --timeout 60000
- name: Run accessibility tests
run: npx playwright test --grep @accessibility
- name: Upload test results
uses: actions/upload-artifact@v4
if: failure()
with:
name: accessibility-report
path: playwright-report/Tag your accessibility tests with @accessibility in the test name so you can run them in isolation:
test('@accessibility homepage should have no violations', async ({ page }) => {
// ...
});Playwright Configuration for Accessibility Tests
// playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './tests',
use: {
baseURL: process.env.BASE_URL || 'http://localhost:3000',
// Run in chromium for axe-core (most consistent results)
// axe-core behavior can vary slightly across engines
},
projects: [
{
name: 'accessibility',
testMatch: /.*\.a11y\.spec\.ts/,
use: {
browserName: 'chromium',
},
},
],
});Snapshot Testing for Violations
Instead of asserting zero violations, you can snapshot the current violation list and detect regressions:
test('violation count should not increase', async ({ page }) => {
await page.goto('/');
const results = await new AxeBuilder({ page }).analyze();
// This will fail if new violations are introduced
expect(results.violations.map((v) => v.id).sort()).toMatchSnapshot();
});Be careful with this approach — it allows the violation list to grow if you update the snapshot without reviewing. It's better for tracking known violations than as a gate.
Generating HTML Reports
For stakeholder reporting, generate an HTML summary of accessibility violations:
import { createHtmlReport } from 'axe-html-reporter';
test.afterEach(async ({}, testInfo) => {
if (testInfo.status === 'failed') {
// Violations are attached via test metadata
}
});
// Or generate from violations directly
const htmlReport = createHtmlReport({
results: accessibilityScanResults,
options: {
projectKey: 'MyApp',
outputDir: 'reports/accessibility',
reportFileName: 'accessibility-report.html',
},
});Install the reporter: npm install --save-dev axe-html-reporter
What axe-core Cannot Catch
Automated tools have limits. axe-core won't catch:
- Cognitive load issues — Is the form too complex? Does the error message make sense?
- Focus order problems — Automated tools can check focus exists but not whether the order makes logical sense.
- Screen reader announcements — axe-core checks ARIA markup but not how screen readers actually announce content.
- Motion and animation —
prefers-reduced-motionsupport requires manual testing. - Touch target sizes — WCAG 2.5.5 requires 44x44px targets; axe-core doesn't reliably catch this.
Pair axe-core automation with periodic manual testing using keyboard navigation and a screen reader (NVDA on Windows, VoiceOver on macOS) to cover what automated tools miss.
Putting It All Together
A mature accessibility test suite in Playwright looks like this:
- Full-page scans on critical user journeys (homepage, checkout, login, signup)
- Component scans on shared components (navigation, modals, forms, data tables)
- Interactive state scans on dynamic UI (open menus, error states, loading states)
- Known violation tracking with documented reasons and issue links
- CI integration that blocks merges on new violations
Start with the first two, add the rest as your test suite matures. The goal isn't 100% automated coverage — it's catching regressions automatically so your manual testing effort can focus on the harder problems.
Accessibility testing with axe-core and Playwright is one of the highest-ROI investments in a modern test suite. The setup is minimal, the feedback is immediate, and the violations it catches are real problems for real users.