Playwright Accessibility Testing with axe-core

Playwright accessibility testing runs axe-core against the browser state reached by a Playwright test. Install the separate @axe-core/playwright package, navigate or interact until the target state is visible, scan it, attach the full result, and fail only on the severities your team gates. A passing scan still does not prove WCAG conformance.
If you want accessibility checks without writing the Playwright glue, Qodex runs axe-core accessibility tests on every pull request from a one-line description.
Choose the gate before you write the test.
| Situation | Gate | Evidence kept |
|---|---|---|
| New or low-debt application | Run a named accessibility project on every pull request and fail on critical and serious violations | Attach the full axe result so non-blocking findings stay visible |
| Legacy application with existing debt | Start with a non-blocking artifact or a tracked issue, then gate net-new rule-and-target fingerprints | Keep the reviewed fingerprint list and shrink it as fixes land, instead of freezing a raw count |
| Dynamic interface | Reach and wait for the intended menu, modal, validation error or completed step before scanning | Attach the result for that named state |
| Large suite | Put shared tags and reviewed exclusions in a fixture, prefer stable CI execution, and shard only when you need to | Keep one result per tested state |
Playwright ships no accessibility engine of its own, so every example here leans on Deque's package. This page is part of our UI testing guide.
What Playwright accessibility testing can and cannot prove
The practice is an ordinary Playwright test with one extra step. It reaches a page state, builds an AxeBuilder around the page, calls analyze(), and asserts on what comes back. Playwright's guide says its examples rely on Deque's @axe-core/playwright package to run the axe engine.
What that finds is real but narrow. It catches text made hard to read by poor contrast, UI controls and form elements without a label a screen reader could identify, duplicate IDs on interactive elements, and missing or invalid properties. Playwright's disclaimer adds that many accessibility problems can only be discovered through manual testing.
W3C is blunter. Evaluation tools cannot check all accessibility aspects automatically, human judgement is required, they sometimes produce false or misleading results, and they cannot determine accessibility on their own. A green scan means only that the rules you selected reported nothing in the state you scanned.
Install @axe-core/playwright and scan the state that matters
Install the package as a dev dependency, pinned to the version this page describes.
npm install --save-dev @axe-core/playwright@4.13.0
On 2 September 2026 the npm registry answered version 4.13.0 for that package. It depends on axe-core at ~4.13.0 and declares playwright-core at >= 1.0.0 as a peer dependency. Deque warns that the package does not follow Semantic Versioning: its major and minor version match the bundled axe-core, while a patch release may add wrapper fixes and features. Pin it if your team pins dependencies.
The wrapper does two jobs. It injects axe into all frames on the page automatically, and it exposes a chainable AxeBuilder API, so a scan reads as one expression: construct it, configure it, analyze.
The smallest version of that is two lines inside a test you already have.
const results = await new AxeBuilder({ page }).analyze();
expect(results.violations).toEqual([]);
analyze() scans the page in the state it is in when you call it. That one sentence decides most of the work. A scan fired straight after page.goto() sees the first render and nothing else: no open navigation menu, no modal, no validation error, no confirmation step. Those states need a locator to do what a user would do, a wait for the revealed region, and a scan after it is on screen.
Two builder methods decide what the scan covers. include() narrows it to one part of the page, which is how you check one component after an interaction. exclude() is the opposite, and it costs more than it looks. Playwright's guide states that exclude() removes the specified elements and all of their descendants, and prevents all rules from running against them, not just the rule you know about. It also warns against using it on components with many children.
That makes an exclusion a blind spot, not a filter. Each one needs a ticket, a named owner and a removal date. The test below excludes #third-party-chat, a widget the team does not control. That selector and the URL are placeholders: swap them for your own test data.
Test WCAG 2.2 with the right axe tags
withTags() constrains a scan to rules tagged for specific WCAG success criteria, which keeps best-practice rules out of a run you intend to gate on. Playwright's own example stops at WCAG 2.1 and uses four tags. A run aimed at Level A and AA through WCAG 2.2 uses five.
.withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa'])
The axe-core 4.13 API tag table lists wcag22aa. It lists no wcag22a tag, so do not invent one. That absence matches the rule table, where the only WCAG 2.2-specific A or AA rule is a Level AA rule.
Here is what those tags reach in the stable axe-core 4.13 rule table, counted from the published rule descriptions on 2 September 2026.
| Stable axe rule-table section | WCAG success criteria represented | Documented count |
|---|---|---|
| WCAG 2.0 A and AA | 1.1.1, 1.2.2, 1.3.1, 1.4.1, 1.4.2, 1.4.3, 1.4.4, 2.1.1, 2.1.3, 2.2.1, 2.2.2, 2.4.1, 2.4.2, 2.4.4, 3.1.1, 3.1.2, 3.3.2, 4.1.2 | 60 rules across 18 criterion codes |
| WCAG 2.1-specific A and AA | 1.3.5 and 1.4.12 | 2 rules across 2 criterion codes |
| WCAG 2.2-specific A and AA | 2.5.8 Target Size (Minimum) | 1 rule, target-size, marked both failure and needs review |
Read that last row beside the standard itself. W3C lists six new A or AA criteria in WCAG 2.2. The stable axe-core 4.13 WCAG 2.2 section documents a direct rule for one of them, 2.5.8, and none for 2.4.11, 2.5.7, 3.2.6, 3.3.7 or 3.3.8.
That comparison counts rules in one table. It is not a claim that automation tells you nothing about the other five behaviours. It does set expectations: adding wcag22aa widens the run by one documented rule, and that rule can come back as needs review rather than a clean pass or fail. The five criteria with no direct rule become the checklist further down this page.
Fail on serious violations and attach the full result
Here is the whole test in one piece. Save it as tests/accessibility.spec.ts.
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
test('checkout has no serious accessibility violations', async ({ page }, testInfo) => {
await page.goto('https://example.com/checkout');
const results = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa'])
.exclude('#third-party-chat')
.analyze();
await testInfo.attach('axe-results.json', {
body: JSON.stringify(results, null, 2),
contentType: 'application/json',
});
const blocking = results.violations.filter(
({ impact }) => impact === 'critical' || impact === 'serious',
);
const summary = blocking
.map((violation) => {
const targets = violation.nodes
.map((node) => node.target.join(', '))
.join('; ');
return `${violation.id} [${violation.impact}]: ${targets}`;
})
.join('\n');
expect(blocking, summary).toEqual([]);
});
Run it.
npx playwright test tests/accessibility.spec.ts
The order of the last three blocks matters more than any of them on their own. testInfo.attach() writes the complete result to the test record before anything can fail, so the evidence survives a red build and your configured reporter can embed or link it. Attach after the assertion and a failing run keeps nothing.
The filter is the policy. This example blocks on critical and serious and lets everything else through, so moderate and minor violations, passed checks and incomplete checks all stay in the attached JSON where somebody can read them. Those two impacts are a choice, not a standard. Pick the line your team will actually hold, then move it when the backlog shrinks.
The summary string turns a failure into something you can act on from the log alone: the rule ID, its impact, and the CSS selectors of the elements that failed. Passing it as the second argument to expect() puts it in the failure message. Our Playwright assertions reference covers how that message and the rest of the matcher family behave.
Handle known violations without permanent blind spots
Every application has failures the day the first scan runs. Work through them in this order.
Fix the violation when your team owns the element. That is our recommendation, not documentation, and it is the only step that improves the application.
If it cannot be fixed now, snapshot a narrow fingerprint built from the axe rule ID and the target selectors of the affected elements, the pattern Playwright's guide shows. Review that file like code: it lists what you agreed to ship broken.
Use
exclude()only for one element you cannot change, on the terms above: a ticket, an owner and a removal date.Use
disableRules()only when one exact rule is temporarily unusable across many elements. Rule IDs come from theidproperty of the violations. Restore the rule on a set date.Do not snapshot the whole
violationsarray. Playwright warns that it carries implementation details such as rendered HTML, so it breaks on unrelated component changes.
A permanent raw count fails more quietly. If the gate allows a fixed count, a fixed failure can be replaced by a new one while the total stays flat, and the build stays green through a regression. Fingerprints catch that: the identity changes even when the total does not.
For an application with real debt, publish the result as an artifact or a tracked issue first, so the list is visible without blocking anyone. Then gate net-new fingerprints and shrink the allowlist as fixes land.
Run accessibility tests on every pull request
Give the accessibility tests their own Playwright project so the pull request job can run them alone.
import { defineConfig } from '@playwright/test';
export default defineConfig({
projects: [
{
name: 'accessibility',
testMatch: /accessibility\.spec\.ts/,
},
],
});
The job itself is three commands. Playwright's CI guide documents the first two as the way to get tests running on any provider, and the project flag comes from its projects guide.
npm ci
npx playwright install --with-deps chromium
npx playwright test --project=accessibility --reporter=html
The assertion is the gate, so no extra step decides pass or fail. Upload the playwright-report folder whether the job passed or failed, because that is where the attached axe-results.json lives and a failing run is exactly when somebody wants it. Playwright also recommends one worker in CI for stability and reproducibility, and sharding across jobs when you need more parallelism than that.
Roll it out by the table at the top. A new or low-debt application can require the critical-and-serious gate from the first day. An older one publishes the report without blocking, gets the fingerprint list reviewed, and only then makes the check required.
What axe cannot test for you
The scan is the cheap half. This is the half a person still does.
Walk the complete keyboard journey, not just whether elements are focusable: tab through the flow, operate every control, and get back out.
Listen to the screen-reader output for the flow, rather than reading the accessibility tree.
Judge whether alternative text conveys the purpose of the image. Automation sees that the attribute exists, not whether the words are right.
Check that keyboard focus is not obscured by a sticky header, a cookie bar or a floating widget, which is 2.4.11 Focus Not Obscured (Minimum).
Check that every drag action has a single-pointer alternative, which is 2.5.7 Dragging Movements.
Check that help sits in the same relative place on every page that offers it, which is 3.2.6 Consistent Help.
Check that the flow does not ask for the same information twice, which is 3.3.7 Redundant Entry.
Check that logging in does not require solving, recalling or transcribing something, which is 3.3.8 Accessible Authentication (Minimum).
Those last five are the criteria with no direct rule in the stable axe-core 4.13 WCAG 2.2 section. The sixth, 2.5.8 Target Size (Minimum), does have target-size, marked both failure and needs review, so a result there can still land on a human. This is a reading of W3C's change list beside Deque's rule table. It marks where the automated evidence stops.
Pa11y vs Lighthouse vs Playwright with axe-core
This is a workflow comparison read from each tool's own documentation. It is not a ranking and not a benchmark, and all three can be right for the same team on different days.
| Tool | Better fit | CI behaviour and output | Limit to state |
|---|---|---|---|
| Playwright with axe-core | The team already has stateful Playwright flows, or has to scan a menu, modal or checkout state after an interaction | A normal test assertion fails the job, and testInfo.attach() keeps the result with the configured reporter | The scan cannot establish WCAG conformance, and manual review is still required |
| Pa11y | The team wants a small URL-list job without adopting Playwright, or wants HTML_CodeSniffer and axe in one run | Supports actions, a threshold, several report formats, and an exit code for CI | A permanent count threshold can hide a replacement defect, and scripted actions are less natural than reusing a stateful Playwright flow |
| Lighthouse | The team already audits broader page quality and wants one accessibility signal beside performance, SEO and the rest | Its accessibility score is a weighted average of pass or fail audits, weighted by axe impact assessments, and it lists manual checks separately | Manual, low-impact and best-practice audits do not affect the score, and a high score is not WCAG conformance |
Pa11y's own README is clear that its default runner is HTML_CodeSniffer and axe is optional, and that --threshold permits a number of findings before its exit code fails the job. Lighthouse's docs are equally clear that each accessibility audit is pass or fail with no partial credit, and that its manual checks, such as logical tab order, sit outside the score. For a wider view of the field, see our roundup of accessibility testing tools, and if the framework itself is the open question, Playwright alternatives compares the runners.
From a page scan to reproducible pull request evidence
An axe result proves which of the rules you selected reported a failure in one rendered state. A UI assertion proves that the intended visible outcome happened. A pull request wants both, plus a way to see what the browser saw.
Qodex drives the real app and logs every step with a screenshot. A failure comes back with the failing step, the failing request and a screenshot attached. The run is saved as standard Playwright the team owns and can export. When the UI changes, a stale test arrives as a diff to approve rather than a silent rewrite. Every pull request runs the suite against its preview, and replays are generated code with no model in the loop.
Run UI checks on every pull request.
Frequently Asked Questions
Does Playwright have built-in accessibility testing?
No. Playwright ships no accessibility engine, and its guide says its examples rely on the separate @axe-core/playwright package to run axe inside ordinary Playwright tests. You write a normal test, reach the state you care about, and call the wrapper on the last line.
How do I install axe-core in Playwright?
Run npm install --save-dev @axe-core/playwright@4.13.0. On 2 September 2026 the registry answered version 4.13.0 for that package, which bundles axe-core at ~4.13.0 and expects playwright-core at >= 1.0.0. Then import AxeBuilder and use the test earlier on this page.
Does axe-core test WCAG 2.2?
Partly. The axe-core 4.13 API supports the wcag22aa tag, but its stable WCAG 2.2 rule table documents one direct A or AA rule, target-size for 2.5.8, marked both failure and needs review. Add the tag. It is not a conformance claim.
Can a passing axe scan prove WCAG compliance?
No. W3C says evaluation tools cannot check all accessibility aspects automatically, that human judgement is required, and that they cannot determine accessibility on their own. A pass means only that the rules you selected reported no failure in the state you scanned.
How do I exclude an element from a Playwright accessibility scan?
Call exclude() with a specific selector on the AxeBuilder chain. Playwright warns that this removes the element and all of its descendants from every axe rule, not just the one you know about, so give each exclusion a ticket, an owner and a removal date.
How do I fail CI only for serious and critical axe violations?
Attach the complete result with testInfo.attach() first, then filter results.violations by impact, then assert the filtered array is empty. That order matters: attaching before the assertion is what keeps the moderate, minor, passed and incomplete checks readable after a red build.
Should Playwright accessibility tests run in every browser?
The wrapper injects axe into all frames, and the scan evaluates whatever state the test reached. We found no source that establishes a browser matrix for accessibility scans, so this page does not recommend one. Playwright recommends one worker in CI for stability, and sharding when you need wider parallelism.
When should I use Pa11y or Lighthouse instead of @axe-core/playwright?
Use Pa11y for a small URL-list job in CI, or when you want HTML_CodeSniffer and axe in one run. Use Lighthouse when you already audit broader page quality and want one accessibility signal beside it. Use Playwright with axe-core for stateful application flows.
The short version
Keep the scan narrow, preserve the full result, block the violations your team chose, and send the parts automation cannot judge to a person before you call a page accessible.





