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

wcag2a, wcag2aa, wcag2aaa

Rules required by WCAG 2.0 level A, AA or AAA.

wcag21a, wcag21aa

Rules added by WCAG 2.1 at level A and AA.

wcag22aa

Rules added by WCAG 2.2 at level AA.

wcag111, wcag143, …​

One tag per WCAG success criterion (wcag111 is 1.1.1 Non-text Content, wcag143 is 1.4.3 Contrast Minimum).

best-practice

Common recommendations that no WCAG criterion requires, for example heading-order. They run by default.

ACT

Rules that implement the W3C Accessibility Conformance Testing rules format.

section508, EN-301-549, RGAAv4, TTv5

Rules that map to the old US Section 508, the European standard EN 301 549, the French RGAA and the Trusted Tester process.

experimental

Cutting-edge rules, disabled by default.

cat.forms, cat.color, cat.aria, …​

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

violations

Rules that failed on at least one element. These are what a test normally asserts to be empty.

passes

Rules that applied and succeeded.

incomplete

Rules that applied but that axe could not decide automatically, such as the contrast of text over an image or an aria-label on an element that has no role. Each of these needs a person to look at it.

inapplicable

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, /books has one heading-order violation (impact moderate, tag best-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) failed color-contrast when the dark theme was selected through the operating system’s prefers-color-scheme setting: red text #b91c1c on the dark background #111827 has a contrast ratio of 2.74, below the required 4.5. Choosing the dark theme with the toggle button fixes it, because only the explicit data-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 are span elements with an aria-label and 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>

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

new AxeBuilder({ page })

Creates a builder for a Playwright Page. The page must come from browser.newContext() or from the page fixture; a page created with browser.newPage() makes analyze() throw "Please use browser.newContext()", because the package needs to open a helper page in the same context.

include(selector)

Limits the scan to the matching elements. Can be called several times. A frame is addressed with a nested array.

exclude(selector)

Removes the matching elements and all their descendants from the scan, for every rule.

withTags(tags)

Runs only the rules that carry at least one of the tags (a string or an array).

withRules(rules)

Runs only the listed rules, by id.

disableRules(rules)

Switches the listed rules off.

options(runOptions)

Passes any axe run option, for example resultTypes: ['violations'], which makes axe keep a single node for passes, incomplete and inapplicable results instead of all of them.

setLegacyMode(true)

Runs the scan through a single page instead of a helper page, with limited iframe support. Not needed in normal use.

analyze()

Runs the scan and resolves to { violations, passes, incomplete, inapplicable, ... }.

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([]);
});

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:

  1. 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.

  2. 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.

  3. 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

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'] } }] : []),
  ],
});

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.

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

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
{
  "devDependencies": { "axe-core": "4.14.0", "cypress": "16.2.0", "cypress-axe": "1.7.0" },
  "overrides": { "cypress-axe": { "cypress": "$cypress" } }
}

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

cy.injectAxe(options?)

Injects axe-core into the page under test. It must run after every cy.visit() or full page load, because a new document has no axe object. The axeCorePath option points to a different copy of axe-core.

cy.configureAxe(configuration)

Calls axe.configure() for the checks that follow; for example it can disable a rule with { rules: [{ id: 'heading-order', enabled: false }] }.

cy.checkA11y(context?, options?, violationCallback?, skipFailures?)

Runs the scan and fails the test if it finds violations. context is a selector or an axe context object with include and exclude; options are the axe run options plus includedImpacts, interval and retries; violationCallback receives the violations (use it to log them); skipFailures: true reports violations through the callback without failing the test.

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(', ')}`);
  });
}

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);
      });
    }
  }

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
  });

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 }] },
  });
}
  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);
  });

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

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.

Running the Cypress examples

The 19 tests of the spec passed in Electron and in Chrome 155, run with npx cypress run --browser electron and --browser chrome. The run also prints a harmless warning, "We failed to trash the existing run results", on this macOS host.

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>

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));
  }
  @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));
  }

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

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])
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"

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

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

@axe-core/playwright, bundles axe-core.

cypress-axe plus axe-core; peer range stops at Cypress 15.

Java com.deque.html.axe-core:selenium, JavaScript @axe-core/webdriverjs, Python axe-selenium-python.

Run a scan

await new AxeBuilder({ page }).analyze()

cy.injectAxe() then cy.checkA11y()

new AxeBuilder().analyze(driver) (Java); axe.inject() then axe.run() (Python).

Fails the test by itself

No: assert on results.violations.

Yes, unless skipFailures is true.

No: assert on the violations.

Restrict to WCAG tags

withTags([...])

{ runOnly: { type: 'tag', values: [...] } }

withTags(List) (Java); runOnly option (Python, JavaScript).

Scope, exclusions

include(), exclude()

Selector or { include, exclude }

include(), exclude() (Java); axe context (Python).

Disable a rule

disableRules([...])

rules option or cy.configureAxe()

disableRules(List) (Java); rules option (Python).

Filter by impact

Filter the returned violations.

includedImpacts

Filter the returned violations.

Full results

Return value; testInfo.attach()

Violation callback only.

Return value; AxeReporter, write_results().

Emulate dark mode

page.emulateMedia({ colorScheme })

DevTools Protocol through Cypress.automation() (Chromium family).

ChromeOptions argument or localStorage; no portable API.

Structure check

toMatchAriaSnapshot()

Explicit assertions with cy.getByRole().

getAccessibleName(), getAriaRole().

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:

  1. 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.

  2. Explicit assertions for behaviour that a scan cannot know: keyboard order, focus after a dialog closes, accessible names of icon buttons, the skip link.

  3. 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.

  4. 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 alt attribute, 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 span with an aria-label and 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 @axe-core/playwright, @axe-core/webdriverjs and the Java artifact. axe-selenium-python 3.0.0 (April 2026) still bundles 4.10.2.

Cypress 16.2

cy.getByRole() query (matching native elements by default, native: false to also match role attributes) and cy.press() for native key presses. cypress-axe 1.7.0 (August 2025) still lists Cypress 15 as the highest supported peer.

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

ariaSnapshotJSON().

Playwright 1.60

expect(page).toMatchAriaSnapshot().

Playwright 1.59

page.ariaSnapshot().

Playwright 1.49

toMatchAriaSnapshot() for locators.

Playwright 1.44

toHaveAccessibleName(), toHaveAccessibleDescription() and toHaveRole().

Selenium 4.51

No accessibility-specific change; getAccessibleName() and getAriaRole() exist since Selenium 4.0.