axe-core Testing Guide: Automated Accessibility Testing in Practice
axe-core is the accessibility testing engine behind Deque's browser extensions, most CI accessibility tools, and the built-in accessibility checks in browser DevTools. Understanding how to use it directly gives you more control over what you test, what you suppress, and how violations surface in your build pipeline.
This guide covers axe-core from initial setup through production CI integration.
What axe-core Actually Tests
axe-core runs a set of rules against the DOM. Each rule tests for a specific accessibility requirement — typically mapped to WCAG 2.x success criteria or ARIA specification requirements.
Rules are organized into:
- Violations: Must be fixed. Definite accessibility failures.
- Passes: Rules that passed for this context.
- Incomplete (needs review): axe found something that requires human judgment. It can't determine pass or fail automatically.
- Inapplicable: Rules that don't apply to the current page content.
Most developers only look at violations. But incomplete results are important — they're the cases axe found suspicious but couldn't definitively classify. Missing alt attributes with non-empty text, ARIA labels that might not be meaningful, color contrast on images.
Installation Options
axe-core works in several contexts:
# Core library
npm install --save-dev axe-core
<span class="hljs-comment"># Playwright integration
npm install --save-dev @axe-core/playwright
<span class="hljs-comment"># Cypress integration
npm install --save-dev cypress-axe
<span class="hljs-comment"># Jest with testing-library
npm install --save-dev jest-axe @testing-library/react @testing-library/jest-domJest Integration (Component Testing)
For React, Vue, or other component-based testing, jest-axe wraps axe-core in a format compatible with Jest matchers:
// src/components/Button.test.jsx
import { render } from '@testing-library/react';
import { axe, toHaveNoViolations } from 'jest-axe';
import { Button } from './Button';
expect.extend(toHaveNoViolations);
describe('Button', () => {
it('is accessible', async () => {
const { container } = render(
<Button onClick={() => {}}>Save changes</Button>
);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('is accessible when disabled', async () => {
const { container } = render(
<Button onClick={() => {}} disabled>
Save changes
</Button>
);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('is accessible with an icon-only variant', async () => {
const { container } = render(
<Button onClick={() => {}} aria-label="Delete item">
<TrashIcon />
</Button>
);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
});Testing Form Components
Forms have the densest concentration of accessibility failures. Test them explicitly:
// src/components/LoginForm.test.jsx
import { render, screen } from '@testing-library/react';
import { axe, toHaveNoViolations } from 'jest-axe';
import { LoginForm } from './LoginForm';
expect.extend(toHaveNoViolations);
describe('LoginForm', () => {
it('is accessible in default state', async () => {
const { container } = render(<LoginForm onSubmit={jest.fn()} />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('is accessible with validation errors', async () => {
const { container } = render(
<LoginForm
onSubmit={jest.fn()}
errors={{
email: 'Please enter a valid email',
password: 'Password must be at least 8 characters',
}}
/>
);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('is accessible with loading state', async () => {
const { container } = render(
<LoginForm onSubmit={jest.fn()} isLoading />
);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
});Playwright Integration (Page-Level Testing)
@axe-core/playwright runs axe against full rendered pages in a real browser. This catches violations that only appear when CSS is applied, when JavaScript has executed, and when real browser rendering is in play.
// tests/a11y/pages.test.js
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
const PAGES_TO_TEST = [
{ name: 'Home', path: '/' },
{ name: 'Login', path: '/login' },
{ name: 'Dashboard', path: '/dashboard' },
{ name: 'Settings', path: '/settings' },
{ name: 'Profile', path: '/profile' },
];
for (const { name, path } of PAGES_TO_TEST) {
test(`${name} page has no accessibility violations`, async ({ page }) => {
await page.goto(path);
// Wait for dynamic content to stabilize
await page.waitForLoadState('networkidle');
const results = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa', 'wcag21aa'])
.analyze();
expect(results.violations).toEqual([]);
});
}Testing Authenticated Pages
// tests/a11y/authenticated-pages.test.js
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
// Use stored auth state from a separate setup step
test.use({ storageState: 'playwright/.auth/user.json' });
test('dashboard is accessible when logged in', async ({ page }) => {
await page.goto('/dashboard');
await page.waitForSelector('[data-testid="dashboard-content"]');
const results = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa'])
.analyze();
expect(results.violations).toEqual([]);
});Scoping Axe to Specific Regions
When a page has a third-party widget you can't fix, scope axe to the parts you own:
test('main content is accessible', async ({ page }) => {
await page.goto('/');
const results = await new AxeBuilder({ page })
.include('main') // only test the main content area
.withTags(['wcag2a', 'wcag2aa'])
.analyze();
expect(results.violations).toEqual([]);
});Or exclude specific elements:
const results = await new AxeBuilder({ page })
.exclude('#intercom-container') // third-party chat widget
.exclude('[data-third-party]')
.withTags(['wcag2a', 'wcag2aa'])
.analyze();Handling the Incomplete Results
Incomplete results are a hint that something might be wrong but axe isn't sure. They require manual verification. Don't ignore them indefinitely — schedule time to review each one.
test('no violations and no unresolved incomplete items', async ({ page }) => {
await page.goto('/');
const results = await new AxeBuilder({ page }).analyze();
// Fail on violations
expect(results.violations).toEqual([]);
// Log incomplete items for manual review (don't fail the build, but track them)
if (results.incomplete.length > 0) {
console.warn('Items requiring manual review:');
results.incomplete.forEach(item => {
console.warn(`- ${item.id}: ${item.description}`);
item.nodes.forEach(node => console.warn(` Node: ${node.html}`));
});
}
});Rule Configuration
You can disable specific rules if they produce false positives in your codebase:
const results = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa'])
.disableRules(['color-contrast']) // if you're relying on server-rendered colors that axe can't see
.analyze();Or enable rules that aren't in the standard tags:
const results = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa'])
.options({
rules: {
'landmark-one-main': { enabled: true },
'region': { enabled: true },
},
})
.analyze();See the axe-core rule descriptions for the full rule catalog.
Surfacing Violations Clearly in CI
Default violation output from axe is dense. Format it for readability:
function formatViolations(violations) {
if (violations.length === 0) return '';
return violations
.map(v => {
const nodes = v.nodes
.map(n => ` - ${n.html}\n Fix: ${n.failureSummary}`)
.join('\n');
return `[${v.impact.toUpperCase()}] ${v.id}\n ${v.description}\n Affected elements:\n${nodes}`;
})
.join('\n\n');
}
test('no violations', async ({ page }) => {
await page.goto('/');
const results = await new AxeBuilder({ page }).analyze();
if (results.violations.length > 0) {
throw new Error(`\nAccessibility violations found:\n\n${formatViolations(results.violations)}`);
}
});Snapshot Testing for Violation Counts
If you have existing violations you can't immediately fix, snapshot the count rather than ignoring violations entirely:
test('violation count does not increase', async ({ page }) => {
await page.goto('/');
const results = await new AxeBuilder({ page }).analyze();
// This will fail if you introduce NEW violations
// Update the snapshot number as you fix violations
expect(results.violations.length).toBeLessThanOrEqual(3);
// Better: snapshot the specific violation IDs so you know what's known
const violationIds = results.violations.map(v => v.id).sort();
expect(violationIds).toEqual(['color-contrast', 'label', 'link-name']);
});CI Integration
Add accessibility tests to your CI pipeline as a separate job that runs alongside your existing tests:
# .github/workflows/accessibility.yml
name: Accessibility
on:
pull_request:
push:
branches: [main]
jobs:
a11y:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- name: Install dependencies
run: npm ci
- name: Install Playwright browsers
run: npx playwright install --with-deps chromium
- name: Build application
run: npm run build
- name: Start application
run: npm start &
env:
NODE_ENV: test
- name: Wait for application
run: npx wait-on http://localhost:3000
- name: Run accessibility tests
run: npx playwright test tests/a11y/
- name: Upload test report
if: always()
uses: actions/upload-artifact@v4
with:
name: accessibility-report
path: playwright-report/What axe-core Cannot Test
Keep these limitations in mind:
- Reading order: axe can't tell if the DOM order matches visual order in a logical way
- Focus management: tabbing through the page requires real keyboard simulation, not static analysis
- Alt text quality: presence is checked, meaningfulness is not
- Error message clarity: axe can tell if errors are programmatically associated with inputs, not if the error message is useful
- Cognitive complexity: no automated tool can evaluate whether content is understandable
- Screen reader behavior: DOM analysis doesn't predict how specific screen readers will announce content
Treat axe as your first line of defense, not your complete accessibility strategy.