Cypress a11y Testing Workflows With cypress-axe
cy.injectAxe() writes a copy of axe-core into the application window, and cy.checkA11y() runs it inside that window and throws if any result survives the impact filter. Both statements have consequences that only surface once a suite is longer than one spec: the injected copy dies with the document, the assertion happens exactly once instead of being retried like every other Cypress assertion, and the default failure message is a single sentence that names a count rather than a defect. This guide is part of Web Accessibility Testing Fundamentals & Tool Selection, and it covers the suite-level work that turns those three rough edges into a stable gate: where injection belongs in the command chain, how to scope a scan to a subject, how to turn the violations array into a readable table in the runner and the terminal, and how the whole thing behaves under cypress run with artifacts attached.
Problem Statement
Cypress runs the spec and the application in the same browser, in two frames, driven by a command queue that is built synchronously and executed asynchronously. That architecture is why cypress-axe is only about a hundred lines of glue — it can reach win.axe directly instead of marshalling a scan across a WebSocket — and it is also the source of every failure mode in this guide. The scanner lives in the application’s window, so the scanner’s lifetime is the document’s lifetime. Anything that replaces the document replaces the scanner with nothing at all.
The practical shape of that problem is a suite where the first spec passes and the fourth spec dies with Cannot read properties of undefined (reading 'run'), because somewhere in between a test submitted a form, followed a link that triggered a server round trip, or called cy.visit() a second time. A client-side route change inside a single-page application keeps the same document and therefore keeps axe; a full page load does not. Injection is not a one-time setup step, it is a per-document step, and the only reliable rule is to re-establish it after anything that loads a document.
The second problem is legibility. A failing cy.checkA11y() produces expected 3 to equal 0 plus three entries in the command log that a reader has to expand one at a time in an interactive runner they do not have, because the failure happened on a CI machine forty minutes ago. The violations array contains everything needed to write a useful report — rule id, impact, help text, and the CSS target of every failing node — and none of it reaches the terminal unless the third argument to cy.checkA11y() does something with it. Wiring that callback is the single highest-value change in this guide, and it is worth doing before the tag set is finalised, because it is what makes the rest of the tuning legible.
beforeEach hook.Key implementation targets:
cypress-axeandaxe-coreboth pinned indevDependencies, registered once in the support file so every spec inherits the commands.- One
cy.scanA11y()wrapper that owns the project’s rule tags, standing exclusions and impact threshold, so no spec ever passes a raw options object. - Idempotent injection that re-establishes axe after any document load without paying for a redundant script evaluation on every scan.
- Scoping by CSS selector, by
include/excludepair, and by a resolved DOM node taken from a Cypress subject, with a clear reason to prefer each. - A violation callback that writes an aligned table to the terminal through
cy.task()and a merged JSON file after the run, instead of leavingexpected 3 to equal 0as the only output. - A
cypress runinvocation whose exit code gates the merge and whose reports, screenshots and JSON summary are uploaded as artifacts.
Prerequisites
1. Install cypress-axe and Register the Commands
cypress-axe declares axe-core as a peer dependency, which means the version that actually gets injected is whatever the package manager resolved — and a minor axe bump can add rules that turn a green suite red without a single line of application code changing. Install both explicitly and pin the axe version, then upgrade it deliberately as its own commit. The rule catalogue is the thing under test here; treating it as a floating transitive dependency is how a Monday-morning pipeline failure becomes a two-hour investigation.
# Install both explicitly so the lockfile records the exact rule catalogue.
npm install --save-dev cypress-axe@1.5.0 axe-core@4.10.2
# Verify which axe version will be injected, not which one is merely installed.
npm ls axe-core # a single deduped entry, no nested second copy
Registering the plugin is one import, and it adds exactly three commands: cy.injectAxe(), which evaluates the axe source inside the application window; cy.checkA11y(), which runs it and asserts; and cy.configureAxe(), which forwards a spec object to axe.configure() for custom rules and locale overrides. Keep the import in a dedicated support module alongside the project’s own wrapper, so the support file stays a table of contents rather than a dumping ground.
// cypress/support/e2e.js
import './a11y'; // registers cypress-axe plus cy.ensureAxe and cy.scanA11y
import './commands'; // the project's existing application-level commands
The first spec should prove the plumbing and nothing else. Visit one static route, inject, scan the whole document, and let it fail — a first run that reports twelve violations on the marketing homepage is a correctly wired scanner, not a broken one. Resist the urge to add exclusions until section 4 gives the output a shape that can be read.
// cypress/e2e/a11y/smoke.cy.js
describe('cypress-axe plumbing', () => {
it('scans the signed-out landing page', () => {
cy.visit('/');
cy.injectAxe(); // must follow the visit, not precede it
cy.get('body[data-app-state="ready"]'); // the app's own readiness marker
cy.checkA11y(); // whole document, every default rule
});
});
2. A Reusable checkA11y Wrapper With the Project’s Rule Tags
Every scan in the suite needs the same tag set, the same standing exclusions for third-party widgets, and the same blocking threshold, and none of that belongs copied into forty specs. The wrapper below is the only place those decisions live. It also solves the injection problem structurally: cy.ensureAxe() reads win.axe and injects only when it is missing, so a spec that navigates four times pays for four injections and no more, and a spec that stays in one document pays for one.
Two details in the tag list matter. wcag2aa covers only the 2.0 success criteria, so a project claiming WCAG 2.2 AA conformance must list wcag21a, wcag21aa and wcag22aa as well or it silently skips SC 2.4.11 (Focus Not Obscured), SC 2.5.8 (Target Size) and the 2.1 additions. And best-practice is not a conformance tag — it contains rules like landmark-one-main and region that are excellent advice and are not WCAG failures, so it belongs in the reported set rather than the blocking set. The tag semantics and the rule-level overrides behind them are covered in the axe-core configuration and setup guide; the wrapper here just consumes them.
// cypress/support/a11y.js
import 'cypress-axe';
// Conformance target: WCAG 2.2 AA. wcag2aa alone stops at the 2.0 criteria.
const BLOCKING_TAGS = ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa'];
// Third-party frames the team cannot fix and does not own.
const STANDING_EXCLUSIONS = [
['#intercom-frame'],
['iframe[src*="player.vimeo.com"]'],
];
Cypress.Commands.add('ensureAxe', () => {
cy.window({ log: false }).then((win) => {
// A document load wipes window.axe; a router transition does not.
if (!win.axe) cy.injectAxe();
});
});
Cypress.Commands.add('scanA11y', (context = null, overrides = {}) => {
cy.ensureAxe();
cy.url({ log: false }).then((url) => {
cy.checkA11y(
context ?? { exclude: STANDING_EXCLUSIONS },
{ runOnly: { type: 'tag', values: BLOCKING_TAGS }, ...overrides },
(violations) => reportViolations(url, violations),
// skipFailures: report without failing while a new gate is soaking.
Cypress.env('a11yWarnOnly') === true,
);
});
});
One trade-off is worth stating plainly rather than discovering later: includedImpacts is applied by cypress-axe before the violation callback runs, so filtering to ['critical', 'serious'] also hides moderate and minor findings from the report. That is the wrong direction — a report should see everything and the gate should block on a subset. The wrapper above therefore omits includedImpacts entirely and pushes the severity decision into the callback in section 4, where the full array is still in hand.
3. Scoping a Check to a Component or Region
The context argument decides which subtree axe walks, and it accepts four shapes that behave differently enough to be worth choosing consciously. Passing nothing scans the whole document, which is right for a page-level gate and wrong for a spec about one widget, because the shared header’s three violations will be attributed to every one of the forty specs that renders it. Passing a CSS selector string scans that element and its descendants. Passing { include, exclude } scans a set minus a set, which is the only shape that can carve a known-bad third-party frame out of an otherwise whole-page scan. Passing a resolved DOM node scans exactly that node, which is what a Cypress subject gives after .then() unwraps the jQuery collection.
// cypress/e2e/a11y/order-table.cy.js
describe('order table accessibility', () => {
beforeEach(() => {
cy.visit('/orders');
cy.get('[data-testid="order-row"]').should('have.length', 25);
});
it('scans the table region only', () => {
cy.scanA11y('#order-table'); // selector string: subtree scan
});
it('scans the page but not the support widget', () => {
cy.scanA11y({ include: [['main']], exclude: [['#intercom-frame']] });
});
it('scans the live filter dialog as a resolved node', () => {
cy.get('[data-testid="filter-dialog"]').should('be.visible');
// .then() unwraps the jQuery collection; axe wants the element itself.
cy.get('[data-testid="filter-dialog"]').then(($dialog) => {
cy.scanA11y($dialog.get(0));
});
});
});
Scoping to a resolved node has one property that no other approach shares: the element is still attached to the real page, so color-contrast resolves against the actual painted background, inherited font weight is real, and a position: fixed overlay that covers the widget is still covering it. That is the difference between a scoped e2e scan and a component-level scan, and it is the reason a component pass is not a substitute for a page pass — a distinction worked through in detail in configuring cypress-axe for component testing.
Two scoping mistakes are common enough to name. Excluding an element does not exclude the rules that reason about the document as a whole — region still evaluates whether all content sits inside a landmark, and it counts the excluded node’s content as unlandmarked, so an exclude for a vendor frame can leave a region violation pointing at nothing actionable. And a selector string that matches multiple elements scans all of them, which is usually what is wanted for [role="tabpanel"] and almost never what is wanted for .card.
4. A Readable Reporter for the Runner and CI
The third argument to cy.checkA11y() is a callback that receives the violations array before the assertion runs. It executes in the browser, in the spec frame, so it can call Cypress.log() to add entries to the command log and cy.task() to hand structured data to the Node process where the terminal lives. Splitting the output across those two destinations is deliberate: the command log is for someone debugging interactively with a DOM to inspect, and the terminal table is for someone reading a CI log with no DOM at all.
// cypress/support/a11y.js (continued)
const BLOCKING_IMPACTS = ['critical', 'serious'];
function reportViolations(url, violations) {
const rows = violations.map((v) => ({
impact: v.impact,
rule: v.id,
nodes: v.nodes.length,
firstTarget: String(v.nodes[0].target[0]).slice(0, 60),
help: v.help,
}));
violations.forEach((v) => {
Cypress.log({
name: 'a11y',
message: `${v.impact} · ${v.id} · ${v.nodes.length} node(s)`,
consoleProps: () => ({ rule: v.id, help: v.help, nodes: v.nodes }),
});
});
// Crosses into the Node process, where console.table and the filesystem live.
cy.task('a11y:report', { spec: Cypress.spec.relative, url, rows }, { log: false });
const blocking = rows.filter((r) => BLOCKING_IMPACTS.includes(r.impact));
if (blocking.length && Cypress.env('a11yWarnOnly') !== true) {
// Our own message names the rules; the default one only names a count.
const summary = blocking.map((r) => `${r.rule} (${r.nodes})`).join(', ');
throw new Error(`${blocking.length} blocking a11y violation(s) on ${url}: ${summary}`);
}
}
The task handler on the Node side owns two outputs. It prints an aligned table immediately, so the failing rule is visible in the CI log next to the test name that produced it, and it accumulates every row in memory so an after:run hook can write one merged JSON file for the whole run. Accumulating in the plugin process rather than appending to a file from the browser avoids the classic cy.writeFile race, where two specs running in the same process overwrite each other’s report.
// cypress.config.js
const { defineConfig } = require('cypress');
const { mkdirSync, writeFileSync } = require('node:fs');
const collected = [];
module.exports = defineConfig({
e2e: {
baseUrl: 'http://127.0.0.1:4173',
video: false, // artifacts come from the JSON report instead
numTestsKeptInMemory: 0, // long a11y runs otherwise exhaust the renderer
retries: { runMode: 0, openMode: 0 }, // a retried a11y pass hides a wait bug
setupNodeEvents(on) {
on('task', {
'a11y:report'({ spec, url, rows }) {
collected.push(...rows.map((r) => ({ spec, url, ...r })));
if (rows.length) {
console.log(`\n accessibility findings — ${url}`);
console.table(rows, ['impact', 'rule', 'nodes', 'firstTarget']);
}
return null; // a task must return null, never undefined
},
});
// after:run fires in run mode without any experimental flag.
on('after:run', () => {
mkdirSync('cypress/reports', { recursive: true });
writeFileSync(
'cypress/reports/a11y-findings.json',
JSON.stringify({ generatedAt: new Date().toISOString(), collected }, null, 2),
);
});
},
},
});
The JSON file this produces is a flat array of rows keyed by spec, URL and rule, which is the shape a downstream annotator wants. Reshaping it into GitHub check annotations or a Slack block payload is covered in structuring JSON violation output for Slack and GitHub annotations, and keeping the row schema stable is what lets that consumer survive an axe upgrade.
5. Running It in cypress run With Artifacts
Interactive mode and run mode differ in ways that matter for a scanner. Run mode uses a headless browser with a fixed 1000×660 viewport unless configured otherwise, which changes which elements are in the viewport, whether a responsive navigation is collapsed behind a disclosure, and therefore which nodes color-contrast and target-size even evaluate. Pin the viewport explicitly in the a11y spec pattern rather than inheriting a default, and pin the browser too — Chrome and Electron resolve system fonts differently, and font metrics feed target-size calculations.
# The a11y gate, exactly as CI runs it.
npx cypress run \
--browser chrome \ # never the bundled Electron for a11y runs
--config viewportWidth=1280,viewportHeight=800 \
--spec 'cypress/e2e/a11y/**/*.cy.js' \
--reporter spec # exit code equals the failing test count
The workflow wires the exit code to the merge decision and uploads the report whether the job passed or failed, because a passing run’s report is the baseline that the next run’s numbers get compared against.
# .github/workflows/cypress-a11y.yml
name: cypress-a11y
on:
pull_request:
paths:
- 'src/**'
- 'cypress/**'
- '.github/workflows/cypress-a11y.yml'
concurrency:
group: cypress-a11y-${{ github.head_ref }}
cancel-in-progress: true
jobs:
scan:
runs-on: ubuntu-24.04
timeout-minutes: 25
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: npm
- run: npm ci
- run: npm run build
- name: Serve the production build on the baseUrl port
run: npx http-server dist -p 4173 --silent &
- name: Wait for the preview server
run: npx wait-on http://127.0.0.1:4173 --timeout 60000
- name: Run the accessibility specs
run: npx cypress run --browser chrome --spec 'cypress/e2e/a11y/**/*.cy.js'
env:
CYPRESS_a11yWarnOnly: ${{ github.event.pull_request.draft && 'true' || 'false' }}
- uses: actions/upload-artifact@v4
if: always()
with:
name: cypress-a11y-findings
path: |
cypress/reports/a11y-findings.json
cypress/screenshots/
retention-days: 14
Pipeline Integration
cypress run exits with the number of failing tests, capped at 255, so any accessibility failure produces a non-zero exit and fails the job with no extra scripting. That is convenient and slightly coarse: one spec failing on a color-contrast regression and one spec failing because the preview server never came up both look like exit code 1. Distinguishing them is what the JSON report is for — a run that produced zero findings and still failed is an infrastructure failure, and a run with findings is a real one. A ten-line step that reads a11y-findings.json and appends a markdown table to $GITHUB_STEP_SUMMARY makes that distinction visible on the run page without anyone downloading an artifact.
Draft pull requests are the natural place for report-only mode. The workflow above sets CYPRESS_a11yWarnOnly from the draft flag, so a work-in-progress branch still gets the full table in its log while skipFailures keeps the job green, and marking the pull request ready for review flips the same specs into blocking mode. Making that transition a property of the pull request rather than of a checked-in config file means nobody has to remember to revert a temporary flag.
For branch protection, register one required status check on the scan job rather than on individual specs, and keep the a11y specs in their own spec pattern so the required check has a stable name as specs are added. The mechanics of turning that check into a hard merge block, including the fork-secrets caveat, are in blocking pull requests on critical accessibility violations.
Troubleshooting and Flaky-Test Mitigation
Cannot read properties of undefined (reading 'run'). The document was replaced after the last injection. Every full page load is a candidate: cy.visit(), a native form submission, a window.location assignment in application code, and an authentication redirect. cy.ensureAxe() inside the wrapper fixes it for scans that go through the wrapper; a spec that calls cy.checkA11y() directly will still hit it, which is a good reason to make the wrapper the only sanctioned entry point.
The scan passes locally and reports nothing in CI. Almost always an empty scope rather than a clean page. A context selector that matches nothing makes axe walk an empty node set and return zero violations, and cypress-axe reports that as a pass. Guard against it by asserting the scope exists first — cy.get('#order-table').should('exist') before cy.scanA11y('#order-table') — so a renamed selector fails as a missing element instead of silently disabling the check.
A single-shot assertion behind a retried query. This is the flakiest interaction in the whole setup and it is worth understanding precisely. Cypress retries queries and the assertions attached to them: cy.get('[data-testid="order-row"]') keeps re-querying until at least one element matches, and .should('have.length', 25) keeps re-querying until the count is 25 or the timeout expires. cy.checkA11y() retries nothing. It runs axe.run() once, against whatever DOM exists at the moment the command dequeues. So a spec that does cy.get('[data-testid="order-row"]') and then scans will frequently scan a table with one row in it, because the query resolved on the first match while the remaining twenty-four were still streaming in. The scan does not fail — it passes, on 4% of the content. The fix is a completeness assertion, not a longer timeout: assert the final count, assert aria-busy has been removed, or assert the skeleton placeholder is gone, and only then scan.
color-contrast results landing in incomplete rather than passes. axe returns incomplete when it cannot determine a background colour, which in a test run usually means the element was mid-transition, sitting on a CSS gradient, or overlapped by a fading modal backdrop. Disable animations for the a11y specs by injecting a stylesheet that zeroes transition-duration and animation-duration, and assert visibility before scanning. Incomplete results are triage material, never a build failure.
Cross-origin scopes. Anything inside cy.origin() runs in a separate browser context with its own window, so an injection performed before the block is invisible inside it. Call cy.injectAxe() and the scan inside the cy.origin() callback, and remember that the callback cannot close over outer-scope variables — pass the tag list in through the args option. This bites hardest on identity-provider redirects, which is one of the reasons authenticated scanning gets its own treatment in scanning authenticated pages in Cypress a11y runs.
Renderer memory exhaustion on long runs. Cypress keeps DOM snapshots for the last 50 tests by default, and an accessibility suite that scans 60 routes will crash the renderer partway through with an out-of-memory error that looks nothing like a test failure. Setting numTestsKeptInMemory: 0 for the a11y project removes the snapshots the run does not need, since the JSON report already carries the evidence.
Common Pitfalls
- Calling
cy.injectAxe()in abefore()hook and expecting it to hold for the whole spec file — Cypress clears the page between tests, so the injection is gone by the secondit(). - Filtering with
includedImpactsand then wondering why the moderate findings never appear in the report: the filter runs before the violation callback, so it removes them from the log as well as from the gate. - Leaving
retries: { runMode: 2 }on the a11y project, which converts a genuine wait bug into an intermittently green suite and destroys the signal that would have found it. - Scanning with a
contextselector that no longer matches after a refactor, which turns the check into a no-op that reports success forever. - Trusting
wcag2aaalone as the tag set for a WCAG 2.2 AA claim, which quietly omits every criterion added in 2.1 and 2.2. - Running the a11y specs against a dev server, so hot-reload client markup, source-map overlays and development-only warnings end up in the violation list.
- Writing the report from the browser with
cy.writeFilein append mode, which races across specs and produces a truncated JSON file roughly one run in ten.
FAQ
Should injection go in a beforeEach hook or inside the scan command?
Inside the scan command, guarded by a check for window.axe. A beforeEach injection is correct exactly until the first test that navigates twice, and then it fails in a way that looks like a plugin bug rather than a lifecycle bug. Making the wrapper responsible for its own precondition means a spec author cannot get the order wrong, and the guard costs one cy.window() call per scan rather than a full script evaluation.
Does cy.checkA11y() retry like a normal Cypress assertion?
No, and this is the most consequential difference between it and the rest of the API. It resolves window.axe, calls axe.run() once, and asserts on the result. There is no re-query and no built-in interval, so every scan is a snapshot of one moment. Determinism has to come from the assertions that precede it — a final element count, an aria-busy check, a readiness attribute — rather than from the scan command itself.
How much of WCAG does this actually cover? The same 30–40% of success criteria that any axe-based scanner covers, because the rule engine is identical whether it runs under Cypress, Playwright or a browser extension. What the Cypress suite adds is state: a scan of a page with a dialog open, a filter applied, or a validation error rendered exercises DOM that a crawler never reaches. The remaining criteria still need manual assistive-technology testing, and no amount of tag tuning changes that ratio.
Is Cypress or Playwright the better host for accessibility scans? They cover the same rules and differ in execution model, cross-origin handling and how injection interacts with navigation, which matters more than either project’s marketing suggests. The trade-offs are worked through side by side in comparing Playwright and Cypress for WCAG compliance testing. The short version: if the suite already exists in Cypress, add scans to it rather than standing up a second runner for accessibility alone.
Can custom axe rules run through cypress-axe?
Yes, through cy.configureAxe(), which forwards a spec object to axe.configure() inside the application window. Call it after injection and before the scan, in the same document, since configure() mutates the injected instance and a document load resets it along with everything else. A bundle of custom checks written for a Playwright run works unchanged here, because the bundle only needs the global axe object to exist.
Related
- Web Accessibility Testing Fundamentals & Tool Selection — the parent section placing Cypress among the five scanning engines.
- Configuring cypress-axe for Component Testing — the mount harness fix and the component-scoped rule set.
- Scanning Authenticated Pages in Cypress a11y Runs —
cy.session(), API-seeded logins and role-specific views. - axe-core Configuration & Setup — the tags, rule overrides and exclusions the wrapper consumes.
- Playwright Accessibility Plugin Integration — the sibling runner, for teams weighing a migration.