Accessibility Checks in E2E Tests
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
An automated accessibility check runs a rule engine against the page a browser test has just driven into some state, and fails the test when a rule is broken. The engine nearly all tools share is axe-core, an open source JavaScript library from Deque Systems that runs inside the page, walks the DOM and the browser’s computed styles and reports violations of rules that map to the Web Content Accessibility Guidelines (WCAG). Playwright, Cypress and Selenium do not ship an accessibility engine of their own; each one injects axe-core through a small integration package, so the rules, the tags and the shape of the results are the same in every tool and only the calling code differs. This page shows that code for all three, then covers the other side of the subject: what a rule engine cannot decide, and the aria snapshot, a Playwright assertion that checks the structure and the accessible names of a region.
It was checked on 25 October 2026 against Playwright 1.64.1, Cypress 16.2.0 and Selenium 4.51.0, with axe-core 4.14.0
(@axe-core/playwright 4.14.0, cypress-axe 1.7.0, com.deque.html.axe-core:selenium 4.14.0 and @axe-core/webdriverjs
4.14.0) and the Python package axe-selenium-python 3.0.0, which bundles axe-core 4.10.2. Every example on this page was run
against the Bookshelf application, in both its light and its
dark theme, except where a sentence says that an example was not run. Because Bookshelf turned out to be almost clean, the
page also scans a small page that is broken on purpose, served from a separate static server, so that every tool can be
seen reporting real violations (see The broken page in Playwright and The broken page in Cypress). The theory of accessibility itself (the POUR
principles, the conformance levels, the legal standards) lives in Web Accessibility; this page
is about wiring checks into tests.
What an automated check can tell you
axe-core evaluates a set of rules. Each rule has an identifier such as color-contrast, image-alt or label, an impact
(minor, moderate, serious or critical), a help URL on the Deque University site that explains the failure, and a list
of tags. The tags are how a test chooses which rules to run.
| Tag | Meaning |
|---|---|
|
Rules required by WCAG 2.0 level A, AA or AAA. |
|
Rules added by WCAG 2.1 at level A and AA. |
|
Rules added by WCAG 2.2 at level AA. |
|
One tag per WCAG success criterion ( |
|
Common recommendations that no WCAG criterion requires, for example |
|
Rules that implement the W3C Accessibility Conformance Testing rules format. |
|
Rules that map to the old US Section 508, the European standard EN 301 549, the French RGAA and the Trusted Tester process. |
|
Cutting-edge rules, disabled by default. |
|
The category a rule belongs to; every rule has one. |
A scan returns four lists, and understanding the difference between them prevents most misreadings:
| List | Meaning |
|---|---|
|
Rules that failed on at least one element. These are what a test normally asserts to be empty. |
|
Rules that applied and succeeded. |
|
Rules that applied but that axe could not decide automatically, such as the contrast of text over an image or an
|
|
Rules that had nothing to check on this page, for example a table rule on a page without tables. |
Two consequences follow. An empty violations list means that no automatable rule failed in the state the test scanned,
not that the page is accessible. And a scan is a statement about one DOM state, so a modal, an error message or a dark
theme is only checked if the test brings the page into that state before it scans.
Bookshelf as the subject
Bookshelf is a small server-rendered application written with accessibility in mind: every page has a lang attribute, a
title, a skip link, landmark regions, labelled form fields and an aria-live result counter. The test runs below showed
that it is nearly, but not entirely, clean:
-
Scanned with the WCAG tags, every public page (
/,/books,/books/3,/signin) and the signed-in pages had no violations in the light theme and in the dark theme, both when the dark theme came from the toggle button and when it came from the operating system preference. -
Scanned with the default rule set,
/bookshas oneheading-orderviolation (impactmoderate, tagbest-practice): the catalogue goes from a level 1 heading straight to the level 3 headings of the book cards. It does not show up in a scan restricted to WCAG tags. -
The error message of the review form (
#review-error) failedcolor-contrastwhen the dark theme was selected through the operating system’sprefers-color-schemesetting: red text#b91c1con the dark background#111827has a contrast ratio of 2.74, below the required 4.5. Choosing the dark theme with the toggle button fixes it, because only the explicitdata-theme="dark"selector overrides the colour. The sections below show the tests that found this. -
On the signed-in admin page and account page axe reported two rules as
incomplete: the star ratings arespanelements with anaria-labeland no role, which axe flags for a human to review (What automated checks cannot find).
The page that is broken on purpose is a "Checkout" page served by a throwaway Node server on its own port. It is not part of Bookshelf. Its markup has one example of each common mistake, and its colour palette fails contrast only in the dark theme:
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Broken checkout</title>
<style>
:root { --bg: #ffffff; --fg: #1f2937; --hint: #5b6472; }
:root[data-theme=dark] { --bg: #111827; --fg: #f3f4f6; --hint: #3b4252; }
body { background: var(--bg); color: var(--fg); font-family: system-ui, sans-serif; margin: 1rem; }
.hint { color: var(--hint); }
.icon-button { width: 2rem; height: 2rem; }
</style>
</head>
<body>
<main>
<h1>Checkout</h1>
<img src="data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='80' height='40'%3E%3Crect width='80' height='40' fill='%23888'/%3E%3C/svg%3E" width="80" height="40">
<form id="payment" action="#">
<input type="text" name="card">
<div id="email-field"><input type="email" name="email" id="email" aria-label="Email"></div>
<div id="email-field2"><input type="email" name="email2" id="email" aria-label="Confirm email"></div>
<button type="button" class="icon-button" data-testid="remove"><svg width="12" height="12"><path d="M0 0L12 12M12 0L0 12" stroke="currentColor"/></svg></button>
<button type="submit">Pay</button>
<a href="/help"><svg width="12" height="12"><circle cx="6" cy="6" r="5"/></svg></a>
</form>
<p class="hint" data-testid="hint">Your card is charged when you press Pay.</p>
<button type="button" id="theme-toggle" onclick="document.documentElement.dataset.theme = document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark'">Toggle theme</button>
</main>
</body>
</html>
Source: Deque University: axe rules
The sections that follow use BASE_URL for Bookshelf and BROKEN_URL for this page.
Playwright
@axe-core/playwright
The package adds an AxeBuilder class to Playwright. It injects axe-core into the page (and into its frames), runs it and
returns the standard axe results object.
npm install --save-dev @axe-core/playwright
The simplest test navigates, scans and asserts on the violations. This one scans the catalogue twice: with the default rule set, which contains one best-practice finding, and with the WCAG A and AA tags, which is clean.
test('the default rule set also runs best-practice rules', async ({ page }) => {
await page.goto('/books');
const results = await new AxeBuilder({ page }).analyze();
expect(results.violations.map((v) => `${v.id}:${v.impact}`)).toEqual(['heading-order:moderate']);
const wcagOnly = await new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa']).analyze();
expect(wcagOnly.violations).toEqual([]);
});
AxeBuilder is a builder: each method returns the builder, and analyze() runs the scan and returns a promise.
| Method | Effect |
|---|---|
|
Creates a builder for a Playwright |
|
Limits the scan to the matching elements. Can be called several times. A frame is addressed with a nested array. |
|
Removes the matching elements and all their descendants from the scan, for every rule. |
|
Runs only the rules that carry at least one of the tags (a string or an array). |
|
Runs only the listed rules, by id. |
|
Switches the listed rules off. |
|
Passes any axe run option, for example |
|
Runs the scan through a single page instead of a helper page, with limited iframe support. Not needed in normal use. |
|
Runs the scan and resolves to |
analyze() scans the page as it is at that moment. If the state under test appears after an interaction, wait for it with
a web-first assertion first, as the review-form example further down does.
Choosing the rules, and scoping the scan
A scan with no configuration runs the default rule set, best-practice rules included: on /books it reports heading-order,
while the same scan restricted to the WCAG tags is clean (the first test of this page checks both). Teams usually restrict
scans to the WCAG A and AA tags, because those are what the standards and the laws require, and keep best-practice findings
as a separate, lower-priority report. A fixture that returns a pre-configured builder keeps that choice in one place:
import { test as base } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
type AxeFixture = {
makeAxeBuilder: () => AxeBuilder;
};
// Every test gets a builder that already checks WCAG 2.0 to 2.2 A and AA rules.
export const test = base.extend<AxeFixture>({
makeAxeBuilder: async ({ page }, use) => {
await use(() =>
new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa']),
);
},
});
export { expect } from '@playwright/test';
// A stable summary of the violations: rule id and the selectors that failed.
export function violationFingerprints(results: { violations: { id: string; nodes: { target: unknown }[] }[] }) {
return JSON.stringify(
results.violations.map((v) => ({ rule: v.id, targets: v.nodes.map((n) => n.target) })),
null,
2,
);
}
The test files then use makeAxeBuilder() instead of new AxeBuilder({ page }). The fixture is built with
test.extend, which Configuration, Fixtures & Projects explains.
The tests below show what include, exclude, options, withRules and disableRules do to a scan of Bookshelf’s catalogue:
test('include and exclude narrow the scan', async ({ page }) => {
await page.goto('/books');
const whole = await new AxeBuilder({ page }).analyze();
const nav = await new AxeBuilder({ page }).include('[data-testid=main-nav]').analyze();
const noNav = await new AxeBuilder({ page }).exclude('[data-testid=main-nav]').analyze();
console.log(`passes: whole=${whole.passes.length} nav=${nav.passes.length} withoutNav=${noNav.passes.length}`);
expect(nav.passes.length).toBeLessThan(whole.passes.length);
});
test('options() passes axe run options such as resultTypes', async ({ page }) => {
await page.goto('/books');
const results = await new AxeBuilder({ page }).options({ resultTypes: ['violations'] }).analyze();
expect(results.violations.map((v) => v.id)).toEqual(['heading-order']);
// Only one node is kept for every result type that was not requested.
expect(results.passes.every((rule) => rule.nodes.length <= 1)).toBe(true);
});
test('withRules runs one rule, disableRules switches rules off', async ({ page }) => {
await page.goto('/');
const one = await new AxeBuilder({ page }).withRules(['color-contrast']).analyze();
expect(one.passes.map((r) => r.id)).toEqual(['color-contrast']);
const without = await new AxeBuilder({ page }).disableRules(['color-contrast']).analyze();
expect(without.passes.map((r) => r.id)).not.toContain('color-contrast');
});
The first of these tests printed passes: whole=42 nav=11 withoutNav=41: scoping the scan to the navigation ran 11 passing rules
instead of 42, and excluding the navigation removed one.
Both themes, and states that need an interaction
Colour contrast is the rule most affected by the theme. Playwright can emulate the operating system preference with
page.emulateMedia({ colorScheme }), which switches prefers-color-scheme for that page, so a loop produces a scan per page
and per scheme. The toggle button is exercised separately because Bookshelf also stores an explicit choice in
localStorage and in data-theme:
for (const colorScheme of ['light', 'dark'] as const) {
for (const path of ['/', '/books', '/books/3', '/signin']) {
test(`${path} has no WCAG A/AA violations (${colorScheme})`, async ({ page, makeAxeBuilder }) => {
await page.emulateMedia({ colorScheme });
await page.goto(path);
const results = await makeAxeBuilder().analyze();
expect(results.violations, violationFingerprints(results)).toEqual([]);
});
}
}
test('the theme toggle switches the page to the dark theme and it still passes', async ({ page, makeAxeBuilder }) => {
await page.emulateMedia({ colorScheme: 'light' });
await page.goto('/books/3');
await page.getByTestId('theme-toggle').click();
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
const results = await makeAxeBuilder().analyze();
expect(results.violations).toEqual([]);
});
Source: Playwright: page.emulateMedia()
The review form in its error state is a page state that only exists after an interaction. The helper below submits a
too-short review and waits for the alert, then the tests scan the main region. This is where the contrast problem of the
error message appeared:
async function submitShortReview(page: Page) {
await signIn(page, 'reader', readerPassword);
await expect(page.getByTestId('account-heading')).toBeVisible();
await page.goto('/books/3');
await page.getByLabel('Your review').fill('Short');
await page.getByTestId('review-submit').click();
await expect(page.getByRole('alert')).toBeVisible(); // wait for the state before scanning
}
test('the review form in its error state (light)', async ({ page, makeAxeBuilder }) => {
await page.emulateMedia({ colorScheme: 'light' });
await submitShortReview(page);
const results = await makeAxeBuilder().include('main').analyze();
expect(results.violations, violationFingerprints(results)).toEqual([]);
});
test('the error message fails colour contrast when dark comes from the OS preference', async ({ page, makeAxeBuilder }) => {
await page.emulateMedia({ colorScheme: 'dark' });
await submitShortReview(page);
const results = await makeAxeBuilder().include('main').analyze();
console.log(violationFingerprints(results));
expect(results.violations.map((v) => v.id)).toEqual(['color-contrast']);
expect(results.violations[0].nodes[0].target).toEqual(['#review-error']);
});
test('the same state passes once the dark theme is chosen with the toggle', async ({ page, makeAxeBuilder }) => {
await page.emulateMedia({ colorScheme: 'light' });
await submitShortReview(page);
await page.getByTestId('theme-toggle').click();
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
const results = await makeAxeBuilder().include('main').analyze();
expect(results.violations).toEqual([]);
});
test('a known issue can be excluded while it is being fixed', async ({ page, makeAxeBuilder }) => {
await page.emulateMedia({ colorScheme: 'dark' });
await submitShortReview(page);
const results = await makeAxeBuilder().include('main').exclude('#review-error').analyze();
expect(results.violations).toEqual([]);
});
The second test printed this fingerprint of the failure (the next section explains why it is a fingerprint rather than the raw violations):
[
{
"rule": "color-contrast",
"targets": [
[
"#review-error"
]
]
}
]
The full violation reports the measured values: foreground #b91c1c, background #111827, contrast ratio 2.74 against the
expected 4.5:1, 16 px normal-weight text. The fourth test shows the usual way to keep a build green while the defect is
fixed, exclude('#review-error'), with the trade-off described next.
Known issues, fingerprints and attachments
When a page has defects that cannot be fixed immediately, the scan must neither fail forever nor stop checking the rest of the page. Playwright’s guide lists three techniques:
-
exclude(selector)removes an element and everything inside it from every rule. It is the simplest option, but a new defect inside that element will go unnoticed. -
disableRules(['rule-id'])switches one rule off for the whole scan. Use it only when many elements break the same rule, and re-enable it when they are fixed. -
A fingerprint snapshot compares only the rule id and the selectors of the violations against a stored value, so existing issues are accepted but a new one fails the test. The raw violations array must not be snapshotted, because it contains the HTML of each element and changes whenever a component is edited.
The fingerprint helper used on this page is violationFingerprints() in the fixture file shown above. With a stored
snapshot the assertion is expect(violationFingerprints(results)).toMatchSnapshot().
For debugging, attach the whole result, including passes and incomplete items, to the test report. Reporters then embed or link the JSON, and the HTML report shows it among the test’s attachments:
test('attach the full scan results', async ({ page, makeAxeBuilder }, testInfo) => {
await page.goto('/books');
const results = await makeAxeBuilder().analyze();
await testInfo.attach('accessibility-scan-results', {
body: JSON.stringify(results, null, 2),
contentType: 'application/json',
});
console.log(`violations=${results.violations.length} passes=${results.passes.length} incomplete=${results.incomplete.length} inapplicable=${results.inapplicable.length}`);
expect(results.violations).toEqual([]);
});
For Bookshelf’s catalogue the test printed violations=0 passes=26 incomplete=0 inapplicable=38 (with the WCAG tags). The
incomplete list is worth reading in a real project; on the admin page it names the rating spans:
test('rules that need a human end up in incomplete', async ({ page }) => {
await signIn(page, 'admin', adminPassword);
await page.goto('/admin');
const results = await new AxeBuilder({ page }).analyze();
console.log(results.incomplete.map((r) => `${r.id}: ${r.nodes.map((n) => n.target).join(' ')}`).join('\n'));
expect(results.violations).toEqual([]);
expect(results.incomplete.map((r) => r.id)).toContain('color-contrast');
});
aria-prohibited-attr: span[aria-label="5 out of 5"] span[aria-label="4 out of 5"] span[aria-label="3 out of 5"]
color-contrast: span[aria-label="5 out of 5"] span[aria-label="4 out of 5"] span[aria-label="3 out of 5"]
The broken page in Playwright
On the page that is broken on purpose the default scan reports five rules:
import { test, expect, violationFingerprints } from './axe-test';
import AxeBuilder from '@axe-core/playwright';
const BROKEN = process.env.BROKEN_URL ?? 'http://localhost:39640/';
test('axe reports the violations of the broken fixture', async ({ page }) => {
await page.goto(BROKEN);
const results = await new AxeBuilder({ page }).analyze();
const summary = results.violations.map((v) => `${v.id} (${v.impact}) x${v.nodes.length}`);
console.log(summary.join('\n'));
expect(results.violations.map((v) => v.id)).toEqual(
expect.arrayContaining(['html-has-lang', 'image-alt', 'label', 'button-name', 'link-name']),
);
});
button-name (critical) x1
html-has-lang (serious) x1
image-alt (critical) x1
label (critical) x1
link-name (serious) x1
Source: Deque University: axe rules
The colour palette of the page passes contrast in its light theme and fails in the dark theme (foreground #3b4252 on
#111827, ratio 1.76), so the same page needs two scans:
test('the dark theme of the fixture fails colour contrast, the light theme does not', async ({ page }) => {
await page.goto(BROKEN);
const light = await new AxeBuilder({ page }).withRules(['color-contrast']).analyze();
expect(light.violations).toEqual([]);
await page.getByRole('button', { name: 'Toggle theme' }).click();
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
const dark = await new AxeBuilder({ page }).withRules(['color-contrast']).analyze();
console.log(violationFingerprints(dark));
expect(dark.violations.map((v) => v.id)).toEqual(['color-contrast']);
console.log(JSON.stringify(dark.violations[0].nodes[0].any[0].data, null, 1));
});
Aria snapshots
An aria snapshot is not an axe rule. It compares the accessibility tree the browser builds for an element, a list of
roles, accessible names and a few states, with a YAML template, so a test can state what a screen reader should be able to
perceive and fail when the structure or a name changes. It therefore complements an axe scan: axe looks for known mistakes,
and a snapshot pins down what the page should contain. The syntax of the template, partial matching, file-based snapshots
and the page.ariaSnapshot() and ariaSnapshotJSON() APIs are explained in
Playwright Assertions; this section shows them used as an
accessibility check.
test('aria snapshot of the main navigation', async ({ page }) => {
await page.goto('/books');
await expect(page.getByRole('navigation', { name: 'Main' })).toMatchAriaSnapshot(`
- navigation "Main":
- link "Catalogue"
- link /Sign in/
- button "Toggle theme"
`);
});
test('aria snapshot of the pagination', async ({ page }) => {
await page.goto('/books');
await expect(page.getByRole('navigation', { name: 'Pagination' })).toMatchAriaSnapshot(`
- navigation "Pagination":
- text: Previous Page 1 of 4
- link "Next"
`);
});
The pagination snapshot is a good example of why snapshots are worth writing against the real output. Bookshelf renders the
disabled "Previous" link without an href, so it is not a link in the accessibility tree, and its text merges with the page
indicator into a single text node, Previous Page 1 of 4. The first attempt, which listed two text nodes, failed for that
reason; the template above is the corrected one, and it passes in Chromium, WebKit and Firefox. The tree of a whole page is available from
page.locator('body').ariaSnapshot(); the start of Bookshelf’s catalogue looks like this:
- link "Skip to content":
- /url: "#main"
- banner:
- link "Bookshelf":
- /url: /
- navigation "Main":
- link "Catalogue":
- /url: /books
- link "Sign in":
- /url: /signin
- button "Toggle theme": Theme
- main:
- heading "Catalogue" [level=1]
- search:
- text: Search books
- searchbox "Search books"
- button "Search"
- paragraph: 24 books found
On the broken page the tree exposes the same defects that axe reports: an img without a name, a textbox without a name,
a button and a link that contain only an unnamed img. A snapshot written for the corrected page fails against it, and
the diff shows where. The test below is marked with test.fail() because it is expected to fail:
import { test, expect } from '@playwright/test';
const BROKEN = process.env.BROKEN_URL ?? 'http://localhost:39640/';
test('a snapshot with accessible names fails on the broken page', async ({ page }) => {
test.fail(); // the page is broken on purpose, so this assertion is expected to fail
await page.goto(BROKEN);
await expect(page.locator('form')).toMatchAriaSnapshot(`
- form:
- textbox "Card number"
- textbox "Email"
- textbox "Confirm email"
- button "Remove item"
- button "Pay"
- link "Help"
`, { timeout: 1000 });
});
The assertion error was:
Error: expect(locator).toMatchAriaSnapshot(expected) failed
Locator: locator('form')
Timeout: 1000ms
- Expected - 7
+ Received + 9
- - form:
- - textbox "Card number"
- - textbox "Email"
+ - textbox
+ - textbox "Email"
- - textbox "Confirm email"
- - button "Remove item"
- - button "Pay"
+ - textbox "Confirm email"
+ - button:
+ - img
+ - button "Pay"
- - link "Help"
+ - link:
+ - /url: /help
+ - img
For single facts the locator assertions toHaveAccessibleName(), toHaveAccessibleDescription() and toHaveRole() (added in
Playwright 1.44) are shorter than a snapshot; they are covered with the other matchers in
Playwright Assertions. Locating elements by role and accessible name, with getByRole(),
is itself a light accessibility check, because a control that lost its name can no longer be found.
Running the Playwright examples
The spec files were run with a single worker, because some tests reset Bookshelf, and /api/test/reset clears the server’s
sessions. The configuration only changes the base URL, the number of workers and the browsers:
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests',
workers: 1, // the tests share the server's sessions, and some reset it
use: {
baseURL: process.env.BASE_URL ?? 'http://localhost:3000',
testIdAttribute: 'data-testid',
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'webkit', use: { ...devices['Desktop Safari'] } },
...(process.env.WITH_FIREFOX ? [{ name: 'firefox', use: { ...devices['Desktop Firefox'] } }] : []),
],
});
Source: Playwright: Test configuration
All the Playwright tests of this page passed in headless Chromium and WebKit on the host and, with WITH_FIREFOX=1, in
Firefox inside the mcr.microsoft.com/playwright:v1.64.1-noble container (Bookshelf reached through
host.docker.internal). Firefox was not run locally because it does not launch on this macOS host. axe-core behaved the
same in the three engines for every rule used here.
Cypress
What the Cypress guide recommends
Cypress’s accessibility guide describes three layers: accessibility scans with an in-test plugin such as cypress-axe,
explicit tests for application-specific behaviour (keyboard navigation, accessible names, alternative text), and
Cypress Accessibility, a paid service in Cypress Cloud. It also warns about two things that are easy to miss. First, a
scan costs time on every call, so it should run on the states that matter and not on every test. Second, finding an element
with a role-based locator does not prove it is accessible: a div with role="button" can be located like a button
but has none of the keyboard behaviour of a real one. The Cypress 16 query cy.getByRole() therefore matches native elements
by default (a div with role="button" is not matched unless native: false is passed), which makes a refactor from a button
to a div fail a test that locates it by role. See
Queries, Assertions & Custom Commands for the query itself.
Source: Cypress: Accessibility testing
Installing cypress-axe with Cypress 16
cypress-axe is a community plugin. Its latest release, 1.7.0, declares the peer dependencies axe-core ^3 || ^4 and
cypress ^10 || ^11 || ^12 || ^13 || ^14 || ^15. Cypress 16.2.0 is outside that range, so a plain install fails:
npm install --save-dev cypress@16.2.0 cypress-axe@1.7.0 axe-core@4.14.0
Source: cypress-axe
npm error code ERESOLVE
npm error ERESOLVE unable to resolve dependency tree
npm error
npm error While resolving: peer@1.0.0
npm error Found: cypress@16.2.0
npm error dev cypress@"16.2.0" from the root project
npm error
npm error Could not resolve dependency:
npm error peer cypress@"^10 || ^11 || ^12 || ^13 || ^14 || ^15" from cypress-axe@1.7.0
Source: npm: legacy-peer-deps
Two fixes work. The examples on this page were installed with the first.
# 1. Accept the incorrect peer range for this install (and remember it in .npmrc)
npm install --save-dev cypress@16.2.0 cypress-axe@1.7.0 axe-core@4.14.0 --legacy-peer-deps
echo "legacy-peer-deps=true" >> .npmrc
Source: npm: legacy-peer-deps
{
"devDependencies": { "axe-core": "4.14.0", "cypress": "16.2.0", "cypress-axe": "1.7.0" },
"overrides": { "cypress-axe": { "cypress": "$cypress" } }
}
Source: npm: package.json overrides
The second form, an overrides entry that makes cypress-axe use the project’s own Cypress version, resolved without
errors in an npm install --dry-run; it limits the exception to this one package instead of switching the peer check off
for the whole tree. Neither fix changes what the plugin does, but it is an unsupported combination until the package
publishes a new peer range, and the 19 Cypress tests below passed in it.
The commands
Importing the plugin in the support file registers three commands. The Bookshelf project’s support file already contains
import 'cypress-axe'.
| Command | Effect |
|---|---|
|
Injects axe-core into the page under test. It must run after every |
|
Calls |
|
Runs the scan and fails the test if it finds violations. |
Source: cypress-axe README
The first tests scan the same four pages as the Playwright suite, in both themes. Cypress has no command for emulating
prefers-color-scheme, so the first block sets the theme key in localStorage before the page loads;
the second block, further down, uses the Chrome DevTools Protocol:
import type { Result } from 'axe-core';
const wcag = { runOnly: { type: 'tag' as const, values: ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa'] } };
// Prints one line per violation in the terminal of `cypress run`.
function logViolations(violations: Result[]) {
cy.task('log', `${violations.length} accessibility violation(s)`);
violations.forEach(({ id, impact, nodes }) => {
cy.task('log', `${id} (${impact}): ${nodes.map((n) => n.target.join(' ')).join(', ')}`);
});
}
Source: cypress-axe README
The log task used by the callback is registered in cypress.config.ts:
import { defineConfig } from 'cypress';
export default defineConfig({
e2e: {
baseUrl: process.env.BASE_URL ?? 'http://localhost:3000',
video: false,
setupNodeEvents(on) {
on('task', {
// Prints a line in the terminal that runs Cypress.
log(message: string) {
console.log(message);
return null;
},
});
},
},
env: { READER_PASSWORD: process.env.BOOKSHELF_READER_PASSWORD ?? 'reader-local-only' },
});
Source: Cypress: task
describe('Bookshelf accessibility', () => {
beforeEach(() => {
cy.request('POST', '/api/test/reset');
});
for (const theme of ['light', 'dark']) {
for (const path of ['/', '/books', '/books/3', '/signin']) {
it(`${path} has no WCAG A/AA violations (${theme})`, () => {
cy.visit(path, {
onBeforeLoad(win) {
win.localStorage.setItem('theme', theme);
},
});
cy.get('html').should('have.attr', 'data-theme', theme);
cy.injectAxe();
cy.checkA11y(null, wcag, logViolations);
});
}
}
Source: cypress-axe README
The tests that scope the scan, as in the Playwright section, are shorter in Cypress because the options live in the command’s arguments:
it('scans only the main region, and only serious or critical impacts', () => {
cy.visit('/books');
cy.injectAxe();
cy.checkA11y('main', { includedImpacts: ['serious', 'critical'] });
});
it('excludes an element and disables one rule', () => {
cy.visit('/books');
cy.injectAxe();
cy.checkA11y(
{ exclude: ['[data-testid=main-nav]'] },
{ rules: { 'heading-order': { enabled: false } } },
);
});
it('configureAxe applies to every later check', () => {
cy.visit('/books');
cy.injectAxe();
cy.configureAxe({ rules: [{ id: 'heading-order', enabled: false }] });
cy.checkA11y();
});
it('the default rule set reports the best-practice heading-order rule', () => {
cy.visit('/books');
cy.injectAxe();
cy.checkA11y(null, null, (violations) => {
expect(violations.map((v) => v.id)).to.deep.equal(['heading-order']);
}, true); // skipFailures: log the violations but do not fail
});
Source: cypress-axe README
includedImpacts filters the violations after the scan, so a team can fail the build only on serious and critical
findings while the rest are reported. In the last test skipFailures (the fourth parameter) is true: the scan still runs, the callback still receives the
violations (it asserts that the only one is heading-order), and the test does not fail. Cypress exposes no equivalent of Playwright’s results object, so the callback is the way to get at the
data, for example to write it to a file with cy.writeFile().
States, themes and known issues
The review form in its error state repeats the contrast finding of the Playwright suite. Cypress.automation() sends a
command to the browser’s DevTools Protocol, so on Chrome, Chromium, Edge and Electron the test can switch the emulated
prefers-color-scheme:
// Emulates prefers-color-scheme through the Chrome DevTools Protocol (Chromium-based browsers only).
function emulateColorScheme(value: 'light' | 'dark') {
return Cypress.automation('remote:debugger:protocol', {
command: 'Emulation.setEmulatedMedia',
params: { features: [{ name: 'prefers-color-scheme', value }] },
});
}
Source: Cypress: Cypress.automation()
function submitShortReview() {
cy.signIn('reader');
cy.visit('/books/3');
cy.get('[data-testid=review-submit]').click();
cy.get('[role=alert]').should('be.visible'); // wait for the state before scanning
cy.injectAxe();
}
it('checks the review form after an interaction (light)', () => {
emulateColorScheme('light');
submitShortReview();
cy.checkA11y('main', wcag, logViolations);
});
it('the error message fails colour contrast when dark comes from the OS preference', () => {
emulateColorScheme('dark');
submitShortReview();
cy.checkA11y('main', wcag, (violations) => {
logViolations(violations);
expect(violations.map((v) => v.id)).to.deep.equal(['color-contrast']);
}, true);
});
it('a known issue can be excluded while it is being fixed', () => {
emulateColorScheme('dark');
submitShortReview();
cy.checkA11y({ include: ['main'], exclude: ['#review-error'] }, wcag);
});
Source: cypress-axe README
The run printed 1 accessibility violation(s) and color-contrast (serious): #review-error for the second test, the same
finding as in Playwright. The emulation uses the DevTools Protocol, so it does not work in Firefox or WebKit.
Explicit accessibility tests
The scan cannot know that the first Tab stop of a page should be the skip link. A short test can, with cy.press() for a
native Tab key press and cy.getByRole() for the link’s role and name:
it('the first Tab stop is the skip link', () => {
cy.visit('/');
cy.press(Cypress.Keyboard.Keys.TAB);
cy.focused().should('have.text', 'Skip to content');
cy.getByRole('link', { name: 'Skip to content' }).should('have.attr', 'href', '#main');
});
Source: Cypress: press
The broken page in Cypress
describe('a deliberately broken page', () => {
const broken = 'http://localhost:39640/';
it('fails the check and lists the violations', () => {
cy.visit(broken);
cy.injectAxe();
cy.checkA11y(null, null, (violations) => {
logViolations(violations);
expect(violations.map((v) => v.id)).to.include.members(['html-has-lang', 'image-alt', 'label', 'button-name', 'link-name']);
}, true);
});
it('checkA11y throws when the violations are not skipped', () => {
cy.on('fail', (error) => {
expect(error.message).to.contain('accessibility violations were detected');
return false;
});
cy.visit(broken);
cy.injectAxe();
cy.checkA11y();
});
it('fails only in the dark theme for colour contrast', () => {
cy.visit(broken);
cy.injectAxe();
cy.checkA11y(null, { runOnly: ['color-contrast'] });
cy.contains('button', 'Toggle theme').click();
cy.get('html').should('have.attr', 'data-theme', 'dark');
cy.checkA11y(null, { runOnly: ['color-contrast'] }, logViolations, true);
});
});
Source: Cypress: visit
Each test visits a single origin, the fixture server, which is a different origin from the baseUrl. The run printed the five violations of the page:
5 accessibility violation(s)
button-name (critical): .icon-button
html-has-lang (serious): html
image-alt (critical): img
label (critical): input[type="text"]
link-name (serious): a
Source: Deque University: axe rules
When violations are not skipped, cy.checkA11y() fails the test with a message such as
AssertionError: 1 accessibility violation was detected: expected 1 to equal 0, which does not name the rule. Pass a
violation callback, as logViolations does, so that the report names them.
Cypress Accessibility in Cypress Cloud
Cypress Accessibility is a commercial feature of Cypress Cloud that does not use in-test scans at all. Runs recorded with Test Replay (Cypress 13 or later) are analysed on the Cloud side with axe-core: no test code or configuration is needed, the checks add no time to the run, and every distinct DOM state reached by a test, in end-to-end or component tests, is analysed. The Cloud then offers sortable and filterable reports by page, component or run, an inspectable DOM snapshot for each violation, a comparison between two runs that shows only the new issues, historical scores, Jira issue creation, and a Results API that a CI job can call to block a pull request. Elements marked as ignored in configuration are left out of the score but stay visible for manual review.
This page’s Cypress Cloud examples were not run: Cypress Accessibility is a paid product that needs a Cloud project, a record key and a trial arranged with Cypress, none of which is available to this documentation. The description above comes from Cypress’s own documentation. See Cypress Cloud for recording runs.
Selenium
Selenium has no accessibility engine either, and its documentation has no page about accessibility checks. The ecosystem
fills the gap with axe-core integrations published by Deque (Java, JavaScript, .NET) and by Mozilla’s services team (Python).
All of them work with a plain WebDriver, and so also with a RemoteWebDriver against a Grid, because the engine runs inside the browser:
the binding injects axe.min.js with executeScript, calls axe.run() and reads the JSON that comes back.
Java
<dependency>
<groupId>com.deque.html.axe-core</groupId>
<artifactId>selenium</artifactId>
<version>4.14.0</version>
<scope>test</scope>
</dependency>
Source: axe-core-maven-html
AxeBuilder offers withTags(List), withRules(List), withOnlyRules(List), disableRules(List), include(String),
exclude(String), withOptions(AxeRunOptions), withOutputFile(String), setTimeout(int) and analyze(WebDriver); analyze(driver, element...)
scans specific elements. Results has getViolations(), getPasses(), getIncomplete() and getInapplicable(), each a list of
Rule objects with getId(), getImpact() and getNodes(). AxeReporter.writeResultsToJsonFile() saves the results.
The Java tests below use JUnit 5, with parameterised tests for the pages and the two themes. The theme is stored in
localStorage, so the test sets it and reloads the page:
package demo;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import com.deque.html.axecore.results.Results;
import com.deque.html.axecore.results.Rule;
import com.deque.html.axecore.selenium.AxeBuilder;
import com.deque.html.axecore.selenium.AxeReporter;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.stream.Collectors;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.openqa.selenium.By;
import org.openqa.selenium.JavascriptExecutor;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.WebElement;
import org.openqa.selenium.chrome.ChromeDriver;
import org.openqa.selenium.chrome.ChromeOptions;
class AccessibilityTest {
static final String BASE_URL = System.getenv().getOrDefault("BASE_URL", "http://localhost:3000");
static final String BROKEN_URL = System.getenv().getOrDefault("BROKEN_URL", "http://localhost:39640/");
static final List<String> WCAG =
List.of("wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22aa");
WebDriver driver;
@BeforeEach
void start() {
ChromeOptions options = new ChromeOptions();
options.addArguments("--headless=new");
driver = new ChromeDriver(options);
}
@AfterEach
void stop() {
driver.quit();
}
static String summary(Results results) {
return results.getViolations().stream()
.map(r -> r.getId() + " (" + r.getImpact() + ") x" + r.getNodes().size())
.collect(Collectors.joining(", "));
}
@ParameterizedTest
@ValueSource(strings = {"/", "/books", "/books/3", "/signin"})
void pageHasNoWcagViolations(String path) {
driver.get(BASE_URL + path);
Results results = new AxeBuilder().withTags(WCAG).analyze(driver);
assertTrue(results.getViolations().isEmpty(), () -> summary(results));
}
@ParameterizedTest
@ValueSource(strings = {"light", "dark"})
void bothThemesHaveNoViolations(String theme) {
driver.get(BASE_URL + "/books/3");
// The theme is stored in localStorage, so set it and load the page again.
((JavascriptExecutor) driver).executeScript("localStorage.setItem('theme', arguments[0])", theme);
driver.navigate().refresh();
assertEquals(theme, driver.findElement(By.tagName("html")).getDomAttribute("data-theme"));
Results results = new AxeBuilder().withTags(WCAG).analyze(driver);
assertTrue(results.getViolations().isEmpty(), () -> summary(results));
}
Source: axe-core-maven-html README
@Test
void includeExcludeAndDisableRules() {
driver.get(BASE_URL + "/books");
// The default rule set contains best-practice rules, so heading-order is reported.
Results whole = new AxeBuilder().analyze(driver);
assertEquals("heading-order (moderate) x1", summary(whole));
Results mainOnly = new AxeBuilder().include("main").exclude("[data-testid=pagination]").analyze(driver);
System.out.println("passes: whole=" + whole.getPasses().size() + " main=" + mainOnly.getPasses().size());
Results withoutRule = new AxeBuilder().disableRules(List.of("heading-order")).analyze(driver);
assertTrue(withoutRule.getViolations().isEmpty());
Results onlyOne = new AxeBuilder().withOnlyRules(List.of("color-contrast")).analyze(driver);
assertEquals(List.of("color-contrast"), onlyOne.getPasses().stream().map(Rule::getId).toList());
}
@Test
void writeTheFullResultsToAFile() throws Exception {
driver.get(BASE_URL + "/books");
Results results = new AxeBuilder().withTags(WCAG).analyze(driver);
AxeReporter.writeResultsToJsonFile("target/axe-results", results);
assertTrue(Files.size(Path.of("target", "axe-results.json")) > 0);
}
@Test
void brokenFixtureIsReported() {
driver.get(BROKEN_URL);
Results results = new AxeBuilder().analyze(driver);
System.out.println(summary(results));
List<String> ids = results.getViolations().stream().map(Rule::getId).toList();
assertTrue(
ids.containsAll(List.of("html-has-lang", "image-alt", "label", "button-name", "link-name")),
ids::toString);
}
@Test
void brokenFixtureFailsContrastOnlyInTheDarkTheme() {
driver.get(BROKEN_URL);
AxeBuilder contrast = new AxeBuilder().withOnlyRules(List.of("color-contrast"));
assertTrue(contrast.analyze(driver).getViolations().isEmpty());
((JavascriptExecutor) driver).executeScript("document.documentElement.dataset.theme = 'dark'");
Results dark = contrast.analyze(driver);
assertEquals("color-contrast (serious) x1", summary(dark));
}
Source: axe-core-maven-html README
Selenium can also ask the browser for what the accessibility tree contains: WebElement.getAccessibleName() and
getAriaRole() return the computed accessible name and role. They are not a replacement for a snapshot of a region, but they
make a targeted check possible:
@Test
void accessibleNameAndRoleComeFromTheBrowser() {
driver.get(BASE_URL + "/books");
WebElement toggle = driver.findElement(By.cssSelector("[data-testid=theme-toggle]"));
assertEquals("Toggle theme", toggle.getAccessibleName());
assertEquals("button", toggle.getAriaRole());
driver.get(BROKEN_URL);
WebElement remove = driver.findElement(By.cssSelector("[data-testid=remove]"));
assertEquals("", remove.getAccessibleName());
assertEquals("button", remove.getAriaRole());
}
}
The 11 Java tests passed with headless Chrome 155 driven by Selenium 4.51.0 on JDK 17. The results file written by
AxeReporter reports "testEngine": {"name": "axe-core", "version": "4.14.0"}: the Java artifact bundles axe-core 4.14.0, in line with @axe-core/playwright.
Python
pip install selenium axe-selenium-python pytest
Source: axe-selenium-python
The Axe class has four methods: inject() loads the bundled axe-core into the page, run(context, options) runs it and
returns the results dictionary, report(violations) formats the violations as readable text, and
write_results(data, name) saves the JSON. Version 3.0.0 bundles axe-core 4.10.2, which is older than the 4.14.0 used by the
Java, JavaScript and Playwright packages, so the same page can be judged by a slightly different rule set.
run() builds JavaScript source with string formatting, and this has one trap. The options are pasted with %s, so a Python
dictionary containing False or True arrives in the browser as the undefined identifier False and the call fails with
JavascriptException: javascript error: False is not defined. Pass the options as a JSON string made with
json.dumps(), as the tests do. The context is pasted with %r, which is valid JavaScript for dictionaries of lists and strings.
import os
import pytest
from selenium import webdriver
BASE_URL = os.environ.get("BASE_URL", "http://localhost:3000")
BROKEN_URL = os.environ.get("BROKEN_URL", "http://localhost:39640/")
@pytest.fixture
def driver():
options = webdriver.ChromeOptions()
options.add_argument("--headless=new")
d = webdriver.Chrome(options=options)
yield d
d.quit()
import json
import pytest
from axe_selenium_python import Axe
from selenium.webdriver.common.by import By
from conftest import BASE_URL, BROKEN_URL
# Axe.run() pastes the options into JavaScript source with "%s", so pass them as a JSON string.
WCAG = json.dumps({"runOnly": {"type": "tag", "values": ["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22aa"]}})
def scan(driver, context=None, options=None):
"""Inject axe-core into the current page and run it."""
axe = Axe(driver)
axe.inject()
return axe, axe.run(context=context, options=options)
@pytest.mark.parametrize("path", ["/", "/books", "/books/3", "/signin"])
@pytest.mark.parametrize("theme", ["light", "dark"])
def test_page_has_no_wcag_violations(driver, path, theme):
driver.get(BASE_URL + path)
# The theme is stored in localStorage, so set it and load the page again.
driver.execute_script("localStorage.setItem('theme', arguments[0])", theme)
driver.refresh()
assert driver.find_element(By.TAG_NAME, "html").get_dom_attribute("data-theme") == theme
axe, results = scan(driver, options=WCAG)
assert results["violations"] == [], axe.report(results["violations"])
def test_context_include_exclude_and_rules(driver):
driver.get(BASE_URL + "/books")
_, whole = scan(driver)
# The default rule set contains best-practice rules, so heading-order is reported.
assert [v["id"] for v in whole["violations"]] == ["heading-order"]
context = {"include": [["main"]], "exclude": [["[data-testid=pagination]"]]} # a dict is fine here
_, part = scan(driver, context=context)
print("passes: whole=%d main=%d" % (len(whole["passes"]), len(part["passes"])))
_, without = scan(driver, options=json.dumps({"rules": {"heading-order": {"enabled": False}}}))
assert without["violations"] == []
_, only = scan(driver, options=json.dumps({"runOnly": ["color-contrast"]}))
assert [r["id"] for r in only["passes"]] == ["color-contrast"]
def test_write_the_full_results_to_a_file(driver, tmp_path):
driver.get(BASE_URL + "/books")
axe, results = scan(driver, options=WCAG)
target = tmp_path / "axe-results.json"
axe.write_results(results, str(target))
assert json.loads(target.read_text())["testEngine"]["name"] == "axe-core"
def test_python_booleans_are_not_valid_javascript(driver):
# Axe.run() pastes a dict with "%s", so a Python False reaches the browser as the identifier False.
driver.get(BASE_URL + "/books")
axe = Axe(driver)
axe.inject()
with pytest.raises(Exception) as error:
axe.run(options={"rules": {"heading-order": {"enabled": False}}})
print(type(error.value).__name__, str(error.value).splitlines()[0])
Source: axe-selenium-python README
def test_broken_fixture_is_reported(driver):
driver.get(BROKEN_URL)
axe, results = scan(driver)
ids = {v["id"] for v in results["violations"]}
print(axe.report(results["violations"]))
assert {"html-has-lang", "image-alt", "label", "button-name", "link-name"} <= ids
def test_broken_fixture_fails_contrast_only_in_the_dark_theme(driver):
driver.get(BROKEN_URL)
options = json.dumps({"runOnly": ["color-contrast"]})
_, light = scan(driver, options=options)
assert light["violations"] == []
driver.execute_script("document.documentElement.dataset.theme = 'dark'")
_, dark = scan(driver, options=options)
assert [v["id"] for v in dark["violations"]] == ["color-contrast"]
def test_accessible_name_and_role_come_from_the_browser(driver):
driver.get(BASE_URL + "/books")
toggle = driver.find_element(By.CSS_SELECTOR, "[data-testid=theme-toggle]")
assert toggle.accessible_name == "Toggle theme"
assert toggle.aria_role == "button"
driver.get(BROKEN_URL)
remove = driver.find_element(By.CSS_SELECTOR, "[data-testid=remove]")
assert remove.accessible_name == ""
assert remove.aria_role == "button"
Source: axe-selenium-python README
The report() text for the broken page lists each rule, its help URL, impact, tags and the failing elements:
Rule Violated:
html-has-lang - Ensure every HTML document has a lang attribute
URL: https://dequeuniversity.com/rules/axe/4.10/html-has-lang?application=axeAPI
Impact Level: serious
Tags: cat.language wcag2a wcag311 TTv5 TT11.a EN-301-549 EN-9.3.1.1 ACT
Elements Affected:
1) Target: html
The <html> element does not have a lang attribute
Source: axe-selenium-python README
The 14 Python tests (the pages-and-themes test is parameterised eight times) passed with headless Chrome 155, Selenium 4.51.0
and pytest on Python 3.13. The Python accessible_name and aria_role properties are the equivalents of the Java methods.
JavaScript and other languages
For Selenium’s JavaScript binding Deque publishes @axe-core/webdriverjs, whose AxeBuilder takes the driver and offers the same builder
methods as the Playwright package (include, exclude, withTags, withRules, disableRules, options, analyze). The
two tests below ran with node --test, Selenium 4.51.0 and headless Chrome:
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { Builder } from 'selenium-webdriver';
import chrome from 'selenium-webdriver/chrome.js';
import AxeBuilder from '@axe-core/webdriverjs';
const BASE_URL = process.env.BASE_URL ?? 'http://localhost:3000';
const BROKEN_URL = process.env.BROKEN_URL ?? 'http://localhost:39640/';
async function withDriver(run) {
const driver = await new Builder()
.forBrowser('chrome')
.setChromeOptions(new chrome.Options().addArguments('--headless=new'))
.build();
try {
await run(driver);
} finally {
await driver.quit();
}
}
test('Bookshelf catalogue has no WCAG violations', () =>
withDriver(async (driver) => {
await driver.get(`${BASE_URL}/books`);
const results = await new AxeBuilder(driver)
.withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa'])
.analyze();
assert.deepEqual(results.violations, []);
}));
test('the broken fixture is reported', () =>
withDriver(async (driver) => {
await driver.get(BROKEN_URL);
const results = await new AxeBuilder(driver).analyze();
const ids = results.violations.map((v) => v.id).sort();
console.log(ids.join(', '));
assert.deepEqual(ids, ['button-name', 'html-has-lang', 'image-alt', 'label', 'link-name']);
}));
The test run printed button-name, html-has-lang, image-alt, label, link-name for the broken page. For .NET, Deque publishes
Deque.AxeCore.Selenium on NuGet; it was not run for this page, and no .NET example is given.
Running Selenium checks on a Grid
Since the engine is injected into the page, the code above works unchanged with RemoteWebDriver and a Selenium Grid or a
cloud grid such as BrowserStack or Sauce Labs: only the driver construction changes (see
Selenium Grid). The Grid and the cloud variants were not run for this page.
Comparing the three tools
| Concern | Playwright | Cypress | Selenium |
|---|---|---|---|
Package |
|
|
Java |
Run a scan |
|
|
|
Fails the test by itself |
No: assert on |
Yes, unless |
No: assert on the violations. |
Restrict to WCAG tags |
|
|
|
Scope, exclusions |
|
Selector or |
|
Disable a rule |
|
|
|
Filter by impact |
Filter the returned violations. |
|
Filter the returned violations. |
Full results |
Return value; |
Violation callback only. |
Return value; |
Emulate dark mode |
|
DevTools Protocol through |
ChromeOptions argument or |
Structure check |
|
Explicit assertions with |
|
Hosted option |
None built in. |
Cypress Accessibility in Cypress Cloud. |
None built in. |
Where to run the checks
Scans are slower than ordinary assertions, because axe evaluates every rule against every element, and the same page state is often reached by many tests. Cypress’s guide recommends to test accessibility at least once for a component, an area or a workflow, and not to repeat it in every test. A practical arrangement is:
-
A scan of each page and of each important state (open dialog, error message, empty state, both themes) in a dedicated accessibility suite, or in the end-to-end test that reaches the state, with the WCAG tags.
-
Explicit assertions for behaviour that a scan cannot know: keyboard order, focus after a dialog closes, accessible names of icon buttons, the skip link.
-
Component tests for reusable widgets (see Playwright Component Testing and Cypress Component Testing), which are faster and show the markup that is meant to be rendered.
-
A CI step that keeps the JSON results as an artifact, so that a failing build can be diagnosed without re-running it. See Running in CI/CD.
The checks are normal tests. They are subject to the same timing problems as any other test, so wait for the state first and see Flaky Tests & Reliability for the general remedies. Checks that run headless and headed give the same answers for the rules used on this page, and the browser matters little because the rules inspect the DOM and the computed styles.
What automated checks cannot find
A clean report means that no mechanically verifiable rule failed. Playwright’s own guide says that many accessibility problems can only be found by manual testing, and recommends combining automated tests, manual assessments and user testing; Cypress’s guide says that no automated scan can prove that an interface works for people with disabilities. Typical gaps:
-
Meaning. axe can tell that an image has an
altattribute, not that the text describes the image. It can tell that a label exists, not that it says what the field is for. -
Reading and focus order. Whether the order of the DOM matches the order of meaning, whether focus moves to a dialog when it opens and returns to the button when it closes, whether a keyboard user can escape a widget (a keyboard trap).
-
Screen reader output. What NVDA, JAWS or VoiceOver actually announces for a live region, a custom widget or a route change in a single-page application.
-
Content and cognition. Plain language, consistent navigation, error messages that explain how to fix the problem, time limits, motion and flashing.
-
Things axe reports as incomplete. The star ratings of Bookshelf are the example from this page: axe cannot decide whether a
spanwith anaria-labeland no role is read at all, and cannot compute the contrast of text made of symbols. -
Anything outside the scanned state. Content that appears only after an interaction, other viewport sizes, zoom and text spacing, forced-colours mode and other themes are checked only if the test creates them. The dark-mode finding in this page appeared only because the tests scanned the error state under both colour schemes.
Manual checks fill the rest: tabbing through the page with the keyboard alone, using a screen reader, zooming to 200 percent, and the guided assessment of Accessibility Insights for Web. Web Accessibility lists the success criteria and the standards, and its section on automated checks makes the same point about the validation tools it describes.
What changed recently
| Version | Change |
|---|---|
axe-core 4.14.0 |
Current at the check date, shared by |
Cypress 16.2 |
|
Cypress Accessibility |
The Cypress Accessibility introduction was last updated on 20 September 2026; the accessibility guide of the Cypress App on 14 October 2026. |
Playwright 1.63 |
|
Playwright 1.60 |
|
Playwright 1.59 |
|
Playwright 1.49 |
|
Playwright 1.44 |
|
Selenium 4.51 |
No accessibility-specific change; |