Automated Accessibility Testing with axe-core and Lighthouse
Automated accessibility testing gives you speed and consistency. Run it on every PR and you'll catch the class of violations that tools can reliably detect — missing labels, contrast failures, invalid ARIA — before they reach production. The key is understanding what each tool covers, how to integrate it into your existing test infrastructure, and how to deal with false positives without silencing legitimate failures.
This guide covers axe-core and Lighthouse in depth: the JS API, integration with Playwright and Puppeteer, violation parsing, the Lighthouse Node API, configuration, and a practical comparison of coverage.
axe-core: What It Is and How It Works
axe-core is an open-source accessibility testing engine developed by Deque. It runs in the browser (or in a headless browser context) and checks the live DOM against a rule set that maps to WCAG 2.1/2.2 and other standards.
The core API is simple: you call axe.run() on a DOM context and get back a results object containing violations, passes, incomplete, and inapplicable rules.
Direct API Usage
Install and inject axe-core into a page:
// Install
npm install axe-core
// Inject in a browser context (e.g., a test page)
const { source } = require('axe-core');
// source is the full axe-core script as a string — inject it via <script> or page.evaluateThe results object structure:
{
violations: [
{
id: 'color-contrast', // rule ID
impact: 'serious', // critical | serious | moderate | minor
description: 'Ensures the contrast between foreground and background colors meets WCAG 2 AA contrast ratio thresholds',
help: 'Elements must meet minimum color contrast ratio thresholds',
helpUrl: 'https://dequeuniversity.com/rules/axe/4.9/color-contrast',
nodes: [
{
html: '<p class="subtitle">Loading...</p>',
failureSummary: 'Fix any of the following:\n Element has insufficient color contrast of 2.85...',
target: ['.subtitle'],
any: [...], // fix one of these
all: [...], // fix all of these
none: [...] // none of these should be true
}
]
}
],
passes: [...],
incomplete: [...], // needs manual review
inapplicable: [...]
}Running axe in Playwright
Playwright is the recommended environment for production-quality automated accessibility testing because it provides a full browser with real rendering.
// Install
npm install --save-dev @axe-core/playwright axe-core
// accessibility.spec.js
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
test.describe('Homepage accessibility', () => {
test('should have no WCAG 2.1 AA violations', async ({ page }) => {
await page.goto('https://example.com');
const results = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'])
.analyze();
expect(results.violations).toEqual([]);
});
test('should have no violations in the navigation', async ({ page }) => {
await page.goto('https://example.com');
const results = await new AxeBuilder({ page })
.include('nav') // only test within <nav>
.withTags(['wcag2a', 'wcag2aa'])
.analyze();
expect(results.violations).toEqual([]);
});
test('modal dialog accessibility', async ({ page }) => {
await page.goto('https://example.com');
await page.click('[data-testid="open-modal"]');
await page.waitForSelector('[role="dialog"]');
const results = await new AxeBuilder({ page })
.include('[role="dialog"]')
.analyze();
expect(results.violations).toEqual([]);
});
});Running axe in Puppeteer
const puppeteer = require('puppeteer');
const { default: AxePuppeteer } = require('@axe-core/puppeteer');
async function runA11yAudit(url) {
const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.goto(url, { waitUntil: 'networkidle0' });
const results = await new AxePuppeteer(page)
.withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'])
.analyze();
await browser.close();
return results;
}
runA11yAudit('https://example.com').then(results => {
if (results.violations.length > 0) {
console.error(`Found ${results.violations.length} accessibility violations:`);
results.violations.forEach(violation => {
console.error(`\n[${violation.impact.toUpperCase()}] ${violation.id}`);
console.error(` ${violation.help}`);
violation.nodes.forEach(node => {
console.error(` - Element: ${node.target}`);
console.error(` ${node.failureSummary}`);
});
});
process.exit(1);
} else {
console.log('No violations found.');
}
});Parsing Violations for Useful Output
Raw axe output is verbose. For CI pipelines, you want structured, actionable output:
function formatViolations(violations) {
return violations.map(v => ({
id: v.id,
impact: v.impact,
description: v.help,
affectedElements: v.nodes.length,
elements: v.nodes.map(n => ({
selector: n.target.join(', '),
html: n.html.substring(0, 150),
fix: n.failureSummary.split('\n')[0]
}))
}));
}
function groupByImpact(violations) {
const groups = { critical: [], serious: [], moderate: [], minor: [] };
violations.forEach(v => groups[v.impact].push(v));
return groups;
}
// Example output
const formatted = formatViolations(results.violations);
const grouped = groupByImpact(results.violations);
console.log(`
Accessibility Audit Results
===========================
Critical: ${grouped.critical.length} violations
Serious: ${grouped.serious.length} violations
Moderate: ${grouped.moderate.length} violations
Minor: ${grouped.minor.length} violations
Details:
`);
formatted.forEach(v => {
console.log(`[${v.impact.toUpperCase()}] ${v.id} — ${v.affectedElements} element(s)`);
console.log(` ${v.description}`);
v.elements.forEach(el => {
console.log(` → ${el.selector}`);
console.log(` Fix: ${el.fix}`);
});
console.log();
});axe Tags and Rule Sets
axe organizes rules by tag. The most useful tags for WCAG conformance testing:
| Tag | Coverage |
|---|---|
wcag2a |
WCAG 2.0 Level A |
wcag2aa |
WCAG 2.0 Level AA |
wcag21a |
WCAG 2.1 additions at Level A |
wcag21aa |
WCAG 2.1 additions at Level AA |
wcag22aa |
WCAG 2.2 additions at Level AA |
best-practice |
Non-WCAG best practices |
ACT |
W3C Accessibility Conformance Testing rules |
For most teams, running ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'] covers WCAG 2.1 AA — the standard required by most regulations.
Configuring Custom Rules
axe-core allows you to add custom rules and disable built-in ones:
const results = await new AxeBuilder({ page })
.options({
rules: {
// Disable a rule you've decided is not applicable
'region': { enabled: false },
// Enable a rule that's off by default
'color-contrast-enhanced': { enabled: true }
}
})
.analyze();Adding a completely custom rule:
const axe = require('axe-core');
axe.configure({
rules: [{
id: 'custom-data-attribute',
selector: '[data-interactive]',
tags: ['custom'],
metadata: {
description: 'data-interactive elements must have an accessible name',
help: 'Add aria-label or aria-labelledby to data-interactive elements'
},
any: ['aria-label', 'aria-labelledby', 'label']
}],
checks: [{
id: 'aria-label',
evaluate: function(node) {
return !!node.getAttribute('aria-label');
}
}]
});Lighthouse Accessibility Audits
Lighthouse is Google's web quality tool. Its accessibility category runs a subset of axe-core rules plus some Lighthouse-specific checks. It returns a score (0–100) and itemized audit results.
CLI Usage
# Install
npm install -g lighthouse
# Basic audit — outputs to HTML
lighthouse https://example.com --only-categories=accessibility
# JSON output for parsing
lighthouse https://example.com \
--only-categories=accessibility \
--output=json \
--output-path=./audit.json \
--chrome-flags="--headless"
# Specify minimum score threshold
lighthouse https://example.com \
--only-categories=accessibility \
--output=json \
--output-path=./audit.json \
--chrome-flags="--headless" && \
node -e "
const report = require('./audit.json');
const score = report.categories.accessibility.score * 100;
console.log('Accessibility score:', score);
if (score < 90) process.exit(1);
"Node API
The Lighthouse Node API gives you programmatic control:
const lighthouse = require('lighthouse');
const chromeLauncher = require('chrome-launcher');
async function runLighthouseAudit(url, options = {}) {
const chrome = await chromeLauncher.launch({
chromeFlags: ['--headless', '--no-sandbox', '--disable-gpu']
});
const config = {
extends: 'lighthouse:default',
settings: {
onlyCategories: ['accessibility'],
formFactor: options.mobile ? 'mobile' : 'desktop',
screenEmulation: options.mobile ? undefined : {
mobile: false,
width: 1350,
height: 940,
deviceScaleFactor: 1,
disabled: false
}
}
};
const runnerResult = await lighthouse(url, {
port: chrome.port,
output: 'json',
logLevel: 'error'
}, config);
await chrome.kill();
return runnerResult.lhr; // Lighthouse Result object
}
async function auditPage(url) {
const lhr = await runLighthouseAudit(url);
const score = lhr.categories.accessibility.score * 100;
const audits = lhr.categories.accessibility.auditRefs;
const failed = audits
.filter(ref => {
const audit = lhr.audits[ref.id];
return audit.score !== null && audit.score < 1;
})
.map(ref => {
const audit = lhr.audits[ref.id];
return {
id: ref.id,
title: audit.title,
description: audit.description,
score: audit.score,
details: audit.details
};
});
return { score, failed, url };
}
// Run audit
auditPage('https://example.com').then(result => {
console.log(`Score: ${result.score}/100`);
console.log(`Failed audits: ${result.failed.length}`);
result.failed.forEach(audit => {
console.log(`\n[FAIL] ${audit.title}`);
if (audit.details?.items?.length) {
audit.details.items.slice(0, 3).forEach(item => {
console.log(` → ${item.node?.snippet || JSON.stringify(item)}`);
});
}
});
});Parsing Lighthouse Results for CI
Lighthouse audit results have a consistent structure for items:
function extractFailedAudits(lhr) {
const accessibilityAuditIds = lhr.categories.accessibility.auditRefs
.map(ref => ref.id);
return accessibilityAuditIds
.map(id => lhr.audits[id])
.filter(audit => audit.score !== null && audit.score < 1)
.map(audit => ({
id: audit.id,
title: audit.title,
score: audit.score,
impact: audit.details?.debugData?.impact || 'unknown',
affectedElements: audit.details?.items?.map(item => ({
snippet: item.node?.snippet,
selector: item.node?.selector,
explanation: item.node?.explanation
})) || []
}));
}axe-core vs Lighthouse: Coverage Comparison
Both tools use axe-core as their engine but Lighthouse uses a specific (and smaller) subset of axe rules plus its own additional checks.
| Rule | axe-core | Lighthouse |
|---|---|---|
| color-contrast | Yes | Yes |
| image-alt | Yes | Yes |
| label | Yes | Yes |
| button-name | Yes | Yes |
| link-name | Yes | Yes |
| document-title | Yes | Yes |
| html-lang-valid | Yes | Yes |
| aria-allowed-attr | Yes | Yes |
| aria-required-attr | Yes | Yes |
| duplicate-id | Yes | No |
| landmark-one-main | Yes | No |
| region | Yes | No |
| scrollable-region-focusable | Yes | No |
| td-headers-attr | Yes | No |
| bypass (skip links) | No | Yes |
| logical-tab-order | No | Yes (manual) |
| focusable-controls | No | Yes |
| use-landmarks | No | Yes |
Key differences:
axe-core advantages:
- More comprehensive rule set (roughly 90+ rules vs Lighthouse's ~30–40 accessibility audits)
- Better at element-level issues (duplicate IDs, ARIA attribute validation)
- Direct integration into test frameworks — runs as part of your test suite
- Can be scoped to specific DOM subtrees
Lighthouse advantages:
- Gives a single score — useful for tracking trend over time
- Includes some audits not in axe (logical tab order, bypass blocks)
- Integrates with Chrome DevTools and PageSpeed Insights
- Built-in report generation with visual output
Practical recommendation: Use axe-core in your test suite for per-PR violation checking. Use Lighthouse for trend monitoring and score tracking on production. They complement each other.
False Positive Handling
axe-core's incomplete array contains results that need manual review — axe found a potential issue but cannot determine if it's a violation without human judgment. These are not false positives; they require a decision.
True false positives — cases where axe reports a violation but the markup is actually correct — are relatively rare but do occur. Common cases:
1. Color contrast on complex backgrounds
axe computes contrast against a flat background color. If your text sits on a gradient or image, axe may report incorrect contrast because it samples a single background pixel.
Handle by disabling the rule for the specific element and documenting why:
const results = await new AxeBuilder({ page })
.exclude('.hero-text') // hero text on image — manually verified 7:1 contrast
.analyze();2. region rule with legitimate non-landmark content
The region rule requires all content to be within a landmark. Some pages have intentional off-landmark content (e.g., skip links, notifications). Disable per element:
const results = await new AxeBuilder({ page })
.options({
rules: { 'region': { enabled: false } }
})
.analyze();3. Third-party content
Ads, embedded widgets, and analytics scripts can generate violations you can't fix. Exclude their containers:
const results = await new AxeBuilder({ page })
.exclude('[data-ad-slot]')
.exclude('#third-party-widget')
.analyze();The key principle: every suppression must be documented with a reason. Undocumented suppressions become technical debt that gets silently expanded over time.
A structured approach to suppression:
// a11y-suppressions.js — centralised, code-reviewed suppression registry
module.exports = {
// Third-party ad iframe — violations outside our control
excludeSelectors: ['[data-ad-container]', '#helpshift-iframe'],
// Color contrast reported on canvas element — not applicable (drawn content)
disabledRules: ['color-contrast'],
// Verified manually: hero image text has 7.2:1 actual contrast
// Automated check fails because gradient background
disabledRulesForElements: {
'.hero__title': ['color-contrast']
}
};Configuring Custom Rule Severity
For projects that want to allow some violations while blocking on others, axe supports impact-based filtering:
test('no critical or serious violations', async ({ page }) => {
await page.goto('/');
const results = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'])
.analyze();
// Allow minor and moderate in initial phase, block on critical and serious
const blockers = results.violations.filter(v =>
['critical', 'serious'].includes(v.impact)
);
if (blockers.length > 0) {
const formatted = blockers.map(v =>
`[${v.impact}] ${v.id}: ${v.nodes.map(n => n.target).join(', ')}`
).join('\n');
throw new Error(`Blocking accessibility violations found:\n${formatted}`);
}
});Integrating into a Page Object Model
For teams using Page Object Models in Playwright, axe checks fit cleanly as a method:
// base-page.js
import AxeBuilder from '@axe-core/playwright';
export class BasePage {
constructor(page) {
this.page = page;
}
async checkAccessibility(options = {}) {
const builder = new AxeBuilder({ page: this.page })
.withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']);
if (options.include) builder.include(options.include);
if (options.exclude) options.exclude.forEach(s => builder.exclude(s));
if (options.disableRules) {
const rules = {};
options.disableRules.forEach(r => { rules[r] = { enabled: false }; });
builder.options({ rules });
}
const results = await builder.analyze();
return results;
}
}
// checkout-page.js
export class CheckoutPage extends BasePage {
async verifyAccessibility() {
// Exclude third-party payment iframe
const results = await this.checkAccessibility({
exclude: ['#payment-iframe']
});
return results.violations;
}
}Running Audits Across Multiple Pages
For a site-wide audit at deployment time:
const lighthouse = require('lighthouse');
const chromeLauncher = require('chrome-launcher');
const fs = require('fs');
const PAGES_TO_AUDIT = [
'/',
'/products',
'/checkout',
'/account',
'/blog'
];
const BASE_URL = process.env.SITE_URL || 'https://example.com';
async function auditAll() {
const chrome = await chromeLauncher.launch({ chromeFlags: ['--headless'] });
const results = [];
for (const path of PAGES_TO_AUDIT) {
const url = `${BASE_URL}${path}`;
console.log(`Auditing ${url}...`);
try {
const lhr = await lighthouse(url, {
port: chrome.port,
output: 'json',
logLevel: 'silent'
}, {
extends: 'lighthouse:default',
settings: { onlyCategories: ['accessibility'] }
});
const score = lhr.lhr.categories.accessibility.score * 100;
results.push({ url, score, status: 'ok' });
} catch (err) {
results.push({ url, score: null, status: 'error', error: err.message });
}
}
await chrome.kill();
// Report
const sorted = results.sort((a, b) => (a.score || 0) - (b.score || 0));
sorted.forEach(r => {
const icon = r.score >= 90 ? '✓' : r.score >= 70 ? '⚠' : '✗';
console.log(`${icon} ${r.score ?? 'ERR'}/100 ${r.url}`);
});
// Fail if any page scores below threshold
const failing = results.filter(r => r.score !== null && r.score < 90);
if (failing.length > 0) {
console.error(`\n${failing.length} page(s) below threshold. Fix before deploying.`);
process.exit(1);
}
}
auditAll().catch(console.error);What Automated Testing Won't Catch
Even with comprehensive axe and Lighthouse integration, you will miss:
- Whether alt text is meaningful (axe only checks presence)
- Whether reading order makes sense (structural validation only)
- Whether keyboard interactions are logical for custom widgets
- Whether focus management is correct after dynamic content changes
- Whether error recovery paths are usable
- Whether the experience is actually understandable to a screen reader user
Automated testing is a floor, not a ceiling. Treat 0 violations as a starting point for manual testing, not a sign-off.
The most effective accessibility programs use automated testing to catch regressions on known-good criteria, freeing human testers to focus on the nuanced judgments that tools cannot make.