Home / Blog / Playwright accessibility testing with axe
Playwright accessibility testing with axe
Playwright runs accessibility checks through the @axe-core/playwright package:
build an AxeBuilder against the page, call analyze(), and assert
that the violations array is empty. That decides contrast failures, missing accessible names
and ARIA attributes a role cannot take. It does not tell you the product is accessible.
Nothing automated does.
docs/accessibility-testing is about nine lines of setup and it works. It leaves you
with a result object and no way to read it. Someone who ran the scan and got a
green tick has the same question as someone who got three hundred violations: what was
examined, and what does this let me say to the person who asked whether the product is
accessible?
What does "add accessibility testing" to a Playwright suite actually mean?
Two different jobs arrive under that phrase, and the ticket almost never says which one it wants.
The first is a scanner. axe-core is a rule engine that reads the rendered DOM and reports what
it can decide from it, and @axe-core/playwright is the package that injects it into
a page your test already has open. One call gives you a result object with dozens of rules
behind it.
The second is assertion. Playwright ships matchers for accessibility properties —
toHaveRole, toHaveAccessibleName and their neighbours — and every
getByRole locator in your suite is already resolving through the browser's
accessibility tree. That half needs no library at all and it fails on the element the developer
just changed.
Whoever wrote your ticket is usually holding a procurement questionnaire or a customer's accessibility review, compressed into four words by the time it reached you. The scan is the quick part; the rest of this page is what you get to say afterwards.
How do you run axe in a Playwright test?
Install the package beside the runner:
npm install --save-dev @axe-core/playwright
Installed here at @axe-core/playwright 4.13.0, which pulled axe-core 4.13.0
Everything below was written against Playwright 1.62 and run on the 1.62.1 an unpinned install
resolved to on the day, with @axe-core/playwright 4.13.0 pulling
axe-core 4.13.0 underneath it. Two version lines matter here, not one. Playwright
decides what the test can do; the axe-core version decides which rules exist and what they are
tagged with, and it moves on its own schedule.
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
test('the orders dashboard has no axe violations', async ({ page }) => {
await page.goto('/dashboard');
const results = await new AxeBuilder({ page }).analyze();
expect(results.violations).toEqual([]);
});
Playwright 1.62.1 · @axe-core/playwright 4.13.0 · TypeScript · tests/dashboard-a11y.spec.ts · fails against the demo application below
The demo application it points at is a small orders dashboard with the defects you would
expect to find in a real one: an icon button with no label, an image with no alt,
grey helper text on white, an ARIA attribute on a control that does not take it, and no
landmarks. The run went red with six violations.
x 1 tests\dashboard-a11y.spec.ts:4:5 › the orders dashboard has no axe violations (1.7s)
1) tests\dashboard-a11y.spec.ts:4:5 › the orders dashboard has no axe violations
Error: expect(received).toEqual(expected) // deep equality
- Expected - 1
+ Received + 498
- Array []
+ Array [
+ Object {
+ "description": "Ensure an element's role supports its ARIA attributes",
+ "help": "Elements must only use supported ARIA attributes",
+ "helpUrl": "https://dequeuniversity.com/rules/axe/4.13/aria-allowed-attr?application=playwright",
+ "id": "aria-allowed-attr",
Playwright 1.62.1 · the run, cut at line 16 of 525
toEqual([]) is the assertion in the documentation and it is the right assertion.
When it fails it prints the whole violation array as a diff: 498 added lines for six problems,
because each violation carries its nodes, each node carries every check that ran on it, and
every check carries its message. The person who has to act on that opens the run and closes it
again. The fix is an attachment, and it sits with the rest of the CI question below.
analyze() reads the page as it stands at the moment of the call. Not the route,
not the component, not the application — the DOM in front of it.
What does an axe scan actually catch?
These are the categories that fired on the demo application, each with a rule id against it so you can go and read the rule for yourself.
Contrast. color-contrast, tagged wcag2aa and
wcag143, which maps it to WCAG SC 1.4.3 Contrast (Minimum), Level AA. The report is
arithmetic and it is specific: "Element has insufficient color contrast of 2.25 (foreground
color: #a8adb7, background color: #ffffff, font size: 12.0pt (16px), font weight: normal).
Expected contrast ratio of 4.5:1".
Missing accessible names. button-name, image-alt and
label, tagged wcag2a with wcag412 or wcag111.
These are the rules that pay for the scan, because a control with no name is invisible to
anybody not using their eyes and completely invisible to the person who wrote it.
ARIA a role cannot honour. aria-allowed-attr and its neighbours.
On the demo application it was aria-sort="ascending" on a
<button>, which sorts a column in the developer's head and does nothing in
the accessibility tree.
Duplicate ids, and only one kind of them now. The documentation lists
duplicate ids among the things a scan catches, the category is real, and one rule behind
it still runs. In the installed axe-core 4.13.0 ruleset, duplicate-id and
duplicate-id-active are tagged deprecated and
wcag2a-obsolete, and both ship disabled, so a default run never reports them. The
criterion behind them, SC 4.1.1 Parsing, was removed from WCAG 2.2.
duplicate-id-aria is still live, tagged wcag2a and
wcag412: a duplicated id that an ARIA attribute points at breaks the name and role
computation, which SC 4.1.2 Name, Role, Value covers.
Landmarks and document structure. region and
landmark-one-main, which axe ships as best-practice rules with no
WCAG tag on them at all.
The scan examines one rendered state. A dialog nobody opened, a route nobody navigated to, an error nobody triggered and a breakpoint nobody sized to were never looked at, and none of that appears in the result as an absence. Scanning the demo dashboard's edit dialog before anything clicks the button returns zero violations. Open it first and the same scan reports an input with no label.
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
test('the edit-customer dialog has no axe violations', async ({ page }) => {
await page.goto('/dashboard');
await page.getByRole('button', { name: 'Edit customer' }).click();
await expect(page.getByRole('dialog', { name: 'Edit customer' })).toBeVisible();
const results = await new AxeBuilder({ page })
.include('#customer-dialog')
.analyze();
expect(results.violations).toEqual([]);
});
Playwright 1.62.1 · @axe-core/playwright 4.13.0 · TypeScript · tests/edit-dialog-a11y.spec.ts · fails with one violation, label
There is a second absence inside the result object itself. analyze() returns four
arrays — violations, passes, incomplete and
inapplicable — and the assertion in the documentation reads one of them. The
default run on the demo dashboard put six entries in violations and one in
incomplete: the duplicated id, which axe flags for review instead of deciding. A
green test and a clean scan are two different results.
Does a passing axe scan mean WCAG compliance?
No. And the setting you would reach for to make it true narrows the run instead of tightening it.
axe rules carry tags. Some of them name a WCAG success criterion, others are best-practice rules that no criterion requires, and the documentation shows you how to run only the first kind:
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
test('the orders dashboard passes the WCAG-tagged rules', async ({ page }) => {
await page.goto('/dashboard');
const results = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'])
.analyze();
expect(results.violations).toEqual([]);
});
Playwright 1.62.1 · @axe-core/playwright 4.13.0 · TypeScript · tests/wcag-tags.spec.ts · fails with four violations
Same page, same application, four violations instead of six. The two that disappeared are
region and landmark-one-main, both tagged best-practice
and neither carrying a WCAG tag. Adding withTags made the run narrower, not
stricter. If you added it because it sounded like the serious setting, you switched rules off.
A tag list is a filter over rules and it is nothing else. axe ships
section508 and EN-301-549 tags beside the WCAG ones, and naming one
runs the rules that carry it. Naming one does not establish that the standard applies to your
product. That comes from your contract, your customer or your lawyer, and it is the input to
the tag list rather than something the tag list tells you.
Then there is the ceiling on the rules that do run, and Playwright's own documentation puts it in one line, in the same section as the WCAG snippet: "Note that automated testing cannot detect all types of WCAG violations." The W3C's Web Accessibility Initiative, who publish the standard, say the same thing as a category limit:
Tools cannot check all accessibility aspects automatically. Human judgement is required. Sometimes evaluation tools can produce false or misleading results. Web accessibility evaluation tools can not determine accessibility, they can only assist in doing so.
So a green scan is a statement about the rules that ran, on the states you scanned, at the moment you scanned them. It is a fact about your test run. Conformance is a claim about a product, made by a person, and a test result is not one.
What does automation miss, and who finds it?
Four states a machine scores as passing and a person scores as broken. None of them is exotic and all four are in software that scans clean.
Focus order that is valid and unusable. Every control is reachable, nothing is trapped, and pressing Tab walks the sidebar, then the footer, then eventually the panel the user just opened. SC 2.4.3 Focus Order, Level A, asks that focus move in an order that keeps the content's meaning and its operability intact. Meaning is a judgement about what the screen is for, and no rule holds a judgement.
A name that exists and says nothing. alt="image", a button called
"button", a live region that announces on every keystroke. The name rules pass on all three,
because a name is present. Listen to the page instead of scanning it and you get "button",
"button", "button", and no idea which one submits the order.
Contrast the rule declined to judge. Put white text on a photograph or a
gradient and axe does not fail it — it reports "Element's background color could not be
determined due to a background gradient" into incomplete, which the usual
assertion never reads. The check came back green because nothing was decided.
A flow that is technically operable and practically impossible. A session timeout nobody can beat, a drag interaction with no keyboard equivalent, an error message announced once and then scrolled past. Every element in the flow can pass every rule.
One boundary sits underneath all four: Playwright does not drive a screen reader. The browser exposes an accessibility tree. A screen reader user hears that tree after a particular reader, in a particular mode, in a particular browser, has interpreted it. No assertion on this page reaches the second thing.
Who does find these: a person operating the product with a keyboard and a screen reader, and
the people it excluded. That work is not what we sell. Firm86 builds and runs the automated
layer: @axe-core/playwright wired into the suite, scoped to the criteria you are
held to. We do not do keyboard walkthroughs, screen-reader passes or assessment against WCAG.
If your requirement needs those, that is work for an accessibility specialist and not for us,
and it is better to hear it now than after a scan comes back green.
What can you check without axe at all?
Playwright ships accessibility matchers that need no library: toHaveRole,
toHaveAccessibleName, toHaveAccessibleDescription,
toHaveAccessibleErrorMessage, and toMatchAriaSnapshot for a whole
subtree.
import { test, expect } from '@playwright/test';
test('the icon buttons in the toolbar expose a role and a name', async ({ page }) => {
await page.goto('/dashboard');
const save = page.locator('.bar button').first();
await expect(save).toHaveRole('button');
await expect(save).toHaveAccessibleName('Save changes');
});
Playwright 1.62.1 · TypeScript · tests/toolbar-names.spec.ts · passes
The element is located structurally, by position in the toolbar, so the assertions describe a property rather than restate the thing they searched by. Assert on a locator that already matched by name and you have written a tautology.
The suite you already have is doing part of this work without anybody deciding to. A spec
written with role-based locators leans on the same machinery axe does.
getByRole('button', { name: 'Save changes' }) resolves through the accessible name
computation, so a locator that cannot
find a control by its name is usually telling you the control has no name a user could refer to
either. On the demo dashboard, the unlabelled icon button that axe reports under
button-name is the same element a role locator cannot reach:
TimeoutError: locator.click: Timeout 3000ms exceeded, with the call log reading
waiting for getByRole('button', { name: 'Filters' }). The locator reported that
defect on the pull request that caused it, months before an audit would have. Which locator to
reach for, and what to do when the recommended one has nothing to match, is
the locator decision on its own terms.
Playwright's documentation draws the line in the same paragraph that recommends the locators: "Note that role locators do not replace accessibility audits and conformance tests, but rather give early feedback about the ARIA guidelines." A failing role locator is evidence about one element's accessible name. It is not an audit and it is not a scan.
How do you fail the build without stopping everyone's work?
The scan meets an application that already exists, and on its first run it returns everything the last four years put there. A suite that fails on all of it gets skipped inside a week. A suite that reports and never fails gets ignored inside two.
The package gives you the mechanisms. exclude() takes a region out of the scan.
disableRules() takes a rule out of the run. The documentation's
Handling known issues section covers both, and adds a third route: a snapshot that
allows one specific known set and fails on anything beyond it. Put whichever you choose in a fixture so every spec scans the same way,
and the list of what you are not scanning exists in one file instead of in thirty.
import { test as base } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
export const test = base.extend<{ makeAxeBuilder: () => AxeBuilder }>({
makeAxeBuilder: async ({ page }, use) => {
await use(() =>
new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'])
// NG-2210, owned by the data team: the legacy usage chart is scanned
// separately until it is rebuilt. Nothing else on any page is excluded.
.exclude('#legacy-chart'));
},
});
export { expect } from '@playwright/test';
Playwright 1.62.1 · @axe-core/playwright 4.13.0 · TypeScript · fixtures/axe-test.ts
import { test, expect } from '../fixtures/axe-test';
test('the settings page passes outside the known exclusions', async ({ page, makeAxeBuilder }, testInfo) => {
await page.goto('/settings');
const results = await makeAxeBuilder().analyze();
await testInfo.attach('axe-results.json', {
body: JSON.stringify(results, null, 2),
contentType: 'application/json',
});
expect(results.violations).toEqual([]);
});
Playwright 1.62.1 · TypeScript · tests/settings-a11y.spec.ts · passes
Without the exclusion that page reports two violations, both inside the legacy chart. With it, zero — and the two lines of comment carry a ticket number and an owner, so the next person to read the fixture can tell how long the list has been there. The attachment is the answer to the 498-line diff earlier: the run's evidence arrives inside the report as a file, and the assertion that failed stays short.
New violations fail the run and the existing set sits in that named file. That works for as long as somebody owns the file, which is the part that outlives whoever added the scan: the engineer who wired axe in changes teams, the fixture keeps running, and the exclusion list quietly grows. That is a suite-ownership problem wearing an accessibility hat, and it is what building and running a Playwright suite is for. The rest of what keeps a suite readable at that size — the conventions a growing suite needs — applies here unchanged.
Questions
Does axe catch all accessibility issues?
No. The W3C's Web Accessibility Initiative states the limit in a single paragraph: "Tools cannot check all accessibility aspects automatically. Human judgement is required", and "Web accessibility evaluation tools can not determine accessibility, they can only assist in doing so." A scan decides the failures a machine can decide on its own: a contrast ratio it can compute, a control with no accessible name at all. Whether the product is usable by the person it excluded is a different question, and nothing in the suite answers it.
Does a passing axe scan mean my site is WCAG compliant?
No. Constraining the run with withTags narrows it: the best-practice rules stop running, so a WCAG-tagged scan checks fewer rules than the default one rather than more. Playwright's own documentation puts the ceiling in one line, in the same section as the WCAG snippet you are about to copy: "Note that automated testing cannot detect all types of WCAG violations." A green run is a statement about the rules that ran, on the states you scanned.
Do I still need axe if my tests already use getByRole?
Yes. A role locator resolves through the accessible name computation, so a spec that cannot find a control by its name is usually telling you the control has no name a user could refer to either, and it says so on the pull request that caused it. It covers the elements your specs already touch, and stops there. Playwright's documentation is explicit about the rest: "Note that role locators do not replace accessibility audits and conformance tests, but rather give early feedback about the ARIA guidelines."
Should accessibility violations fail the build?
New ones, yes. A scan that fails on every violation a legacy application already carries gets skipped inside a week, and one that reports without ever failing gets ignored inside two. The arrangement that survives contact with a team is a run that goes red on anything new while the existing set sits in one file — an exclude call or a disabled rule per item, each with a ticket number and a name beside it, so the list can be read and its length belongs to somebody.
Who owns the scan a year from now?
Adding axe to one spec is an afternoon and you do not need us for it. The question worth an email is the one after that: which states of your application a scan should run against, who reads a red run at 9am, and what stops the exclusion list growing once the person who wrote it has moved to another team. Send the names of the specs you have today and what your procurement form or contract asks for, and we will tell you which of it a suite can carry and which of it needs a person doing the work by hand.