Adding accessibility tests with axe — a practical walkthrough
axe-core is the engine behind most accessibility testing in 2026 — and it's surprisingly approachable once you separate it from the marketing. Here's a practical walkthrough of integrating axe with Playwright, what it actually catches, and what it can't see.
part ofAccessibility for QAWhat axe-core actually is
Deque Systems has maintained axe-core since 2015. It's the open-source rule engine that powers Lighthouse's accessibility audit, the browser extensions for Chrome and Firefox, and the majority of tools that claim to do WCAG compliance checking. When a product says "powered by axe" or "automated accessibility testing," it's almost certainly wrapping axe-core under the hood.
Understanding this matters for two reasons. First, most accessibility tooling is doing the same underlying checks — running Lighthouse, the axe Chrome extension, and @axe-core/playwright will broadly surface the same violations. The difference is integration depth, not detection capability. Second, it means the rule set is stable and well-documented: axe-core's rules map directly to WCAG success criteria, and Deque publishes the full list with explanations of what each rule checks and why.
axe-core works by inspecting the rendered DOM against a rule set. Each rule corresponds to a WCAG criterion or an established best practice. Rules check things like: does every image have an alt attribute? Do form inputs have programmatic labels? Is there sufficient colour contrast between text and background? Are ARIA roles applied to elements that support them? Is there a mechanism to bypass navigation and jump to main content?
As of 2026, axe-core ships around 120 rules. About 60 are enabled by default when running with WCAG 2.1 AA configuration — the level that most legal accessibility requirements reference. The rest are either best-practice (useful but outside the WCAG spec) or experimental.
Why Playwright for a11y testing
Both Playwright and Cypress can run axe-core. The @axe-core/playwright and cypress-axe packages wrap the same underlying library. But Playwright has a meaningful structural advantage for accessibility work that goes beyond automated axe checks.
Playwright communicates with the browser via the Chrome DevTools Protocol from outside the browser process. This gives it access to APIs that Cypress — which runs JavaScript inside the browser context — doesn't expose in the same way. Most relevant for accessibility: page.accessibility.snapshot(), which returns the browser's internal accessibility tree. This is what assistive technologies like screen readers actually consume — not the DOM, but the interpreted accessibility model derived from it.
For standard axe integration, this doesn't affect which violations you catch. axe-core runs inside the page context regardless of the test runner. But for building more advanced accessibility tests — verifying computed accessible names, checking that ARIA live regions are structured correctly, inspecting focus management after interactions — Playwright's DevTools Protocol access provides capabilities Cypress can't match.
If you're building a mature accessibility test programme, Playwright is the stronger foundation. If you're adding axe checks to an existing Cypress suite, cypress-axe is a reasonable starting point for the automated rule checks.
The integration
Install the package:
npm install --save-dev @axe-core/playwrightThe minimal integration is straightforward:
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
test.describe('home page accessibility', () => {
test('has no automatically detectable WCAG AA violations', async ({ page }) => {
await page.goto('/');
const results = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'])
.analyze();
expect(results.violations).toEqual([]);
});
});For larger pages where you want to scope the check to a specific region — a checkout form, a navigation component, a modal — use .include():
const results = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa'])
.include('#main-content')
.analyze();For rules that represent known violations you're tracking but haven't resolved yet, .disableRules() lets you exclude specific rules without turning off the full scan:
const results = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa'])
.disableRules(['color-contrast'])
.analyze();When a violation occurs, the results.violations array is specific: it tells you which rule triggered, which DOM element failed it, why it failed, and which WCAG criterion it maps to:
// Log violations for debugging — the output is detailed
if (results.violations.length > 0) {
console.log(JSON.stringify(results.violations, null, 2));
}
expect(results.violations).toEqual([]);A typical violation entry looks like:
{
"id": "label",
"description": "Ensures every form element has a label",
"help": "Form elements must have labels",
"helpUrl": "https://dequeuniversity.com/rules/axe/4.9/label",
"impact": "critical",
"nodes": [
{
"html": "<input type=\"email\" name=\"email\" />",
"target": ["#email"],
"failureSummary": "Fix any of the following: ...",
"any": [...]
}
]
}What axe catches — and what it misses
What axe catches reliably:
- Colour contrast failures (text/background ratio below 4.5:1 for normal text, 3:1 for large text at 18px+)
- Images without alt text
- Form inputs without associated labels (via
<label for="">,aria-label, oraria-labelledby) - ARIA role misuse (landmark roles, button vs link semantics, roles applied to unsupported elements)
- Heading hierarchy violations (
<h3>after<h1>with no<h2>, multiple<h1>elements) - Interactive elements lacking accessible names
- Missing skip-navigation links
- Page without a
langattribute
What no automated tool can catch:
- Focus order — whether tab order through the page matches the visual layout and makes spatial sense
- Keyboard traps — whether modals and custom widgets allow keyboard users to exit
- Screen reader announcements — what NVDA, JAWS, or VoiceOver actually says when a user navigates or interacts
- Dynamic content announcements — whether ARIA live regions announce search results, errors, or loading states correctly and at the right verbosity
- Cognitive accessibility — whether instructions are clear, whether error messages explain what went wrong and how to fix it
The figure that comes up repeatedly in accessibility research: automated tools catch approximately 30–40% of WCAG failures. Lighthouse, which runs axe under the hood, covers a similar subset. The remaining 60–70% require a human tester with a keyboard and a screen reader.
The WCAG-AA defaults vs the rules worth reconsidering
Running with ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'] tags gives you WCAG 2.0 and 2.1 Level A and AA — the right target for legal compliance in most jurisdictions. That said, a few rules produce significant friction for teams early in their accessibility journey.
color-contrast is correct and important but will fire prolifically on legacy designs, on placeholder text, and on anything that was built before contrast was considered. For codebases with existing contrast debt, disabling temporarily and tracking as a backlog item is defensible.
region requires all content to be contained within a landmark region (<main>, <nav>, <header>, <footer>, <aside>). If your layout doesn't use landmark elements, this will fire on every page. The fix is straightforward — add landmark elements to the layout template. Don't disable this rule; fix the layout.
duplicate-id is valid — duplicate HTML IDs break ARIA relationships and cause screen reader confusion. Some third-party component libraries generate duplicate IDs internally. If a library has a known open issue for this and you're tracking it, suppressing specific elements with .exclude() is cleaner than disabling the rule globally.
The practical starting strategy: enable the full WCAG AA rule set. Accept the initial violation count as a backlog. Fix the structural issues first — landmark regions, form labels, heading hierarchy, alt text. Track contrast and third-party library issues separately. Consider deploying in warning mode (report violations, don't fail the build) until the baseline is clean, then switch to fail mode as the starting point for regressions.
// related
Your Lighthouse score isn't an accessibility test
A 100 Lighthouse accessibility score doesn't mean your site is accessible. The score is a smoke alarm — useful, but not a test. Here's what it actually measures, and what you still need to check manually.
Playwright vs Cypress in 2026: an honest comparison
After shipping production suites in both, here's the honest breakdown — where Playwright pulls ahead, where Cypress still wins, and the single factor that should actually decide it.