Configuring cypress-axe for Component Testing
A component spec that mounts one card and scans it will report six violations before the component has done anything wrong: the document has no lang, no title, no <main>, and no <h1>. This guide is part of Cypress a11y Testing Workflows, and it covers the two-part fix — a mount wrapper that supplies the document context cy.mount() leaves out, and a component-scoped rule set that switches off the page-level rules no harness can answer honestly — plus the reason a green component spec is not evidence that the assembled page passes.
Root Cause
Cypress component testing serves a nearly empty HTML document from its own dev server. The body contains a single <div data-cy-root>, cy.mount() renders the component into that div, and everything else a real page has is simply absent. There is no lang attribute on <html>, the <title> is whatever the harness template shipped, there is no landmark structure, and there is no heading above the component’s own headings. That is exactly the right harness for a rendering test and exactly the wrong one for a scanner. The framework adapter — cypress/react, cypress/vue or the mount function from a framework preset — controls what goes inside [data-cy-root] and nothing above it, which is why the fix has to reach the harness document directly rather than going through the same provider wiring described in the guide on configuring axe-core for React and Vue applications.
axe evaluates rules against the document it is given, and a large group of rules is document-scoped by design. html-has-lang and html-lang-valid read document.documentElement. document-title reads document.title. landmark-one-main counts main landmarks in the whole document. region walks every top-level content node and asks whether it sits inside a landmark. page-has-heading-one looks for an h1 anywhere. Passing a narrow context to cy.checkA11y() does not rescue any of them, because the context restricts which nodes are reported, not which document the rule reasons about — and region in particular will still flag the mounted component’s own root as content outside a landmark, because in that document it genuinely is.
The result is six findings per component spec, all of them about the harness, none of them about the component. A team that meets this on day one has two bad options and one good one. Muting the whole best-practice tag throws away region and landmark-one-main for the e2e suite too if the tag list is shared. Adding a blanket ignore for every failing rule id silently disables bypass and heading-order, which are real criteria that a page scan needs. The good option is to make the harness look enough like a document that the document rules can answer truthfully, and then disable only the handful that remain genuinely unanswerable — the ones whose correct answer depends on what else is on the page.
[data-cy-root] or on the root itself, so no wrapper element is inserted into the component's own ancestry.Configuration
The wrapper does four things to the harness document and nothing to the component. It sets lang on the root element, gives the document a title, promotes [data-cy-root] to a main landmark with role="main", and inserts a <header> containing a single h1 as the first child of <body>. That last detail matters: putting the synthetic heading inside a header element makes it a banner landmark, so region sees no content outside a landmark. Marking the root with role rather than wrapping the component in a real <main> element avoids the trap that catches most teams — a wrapper element changes :first-child, direct-descendant selectors and flex context for the component under test, so the accessibility fix quietly breaks the styling tests in the same spec file.
Injection is the other decision baked into the wrapper. cy.injectAxe() evaluates the entire axe-core source inside the harness window, which costs roughly a tenth of a second on a CI runner. Cypress does not reload the component-testing document between the tests in a spec file, so the injected global survives from one cy.mount() to the next, and a spec with thirty test cases that injects per mount burns several seconds for nothing. The guard below injects on the first scan of the document and never again, and because the condition is win.axe rather than a spec-level flag it still does the right thing if the harness is reloaded mid-spec.
// cypress/support/component.js
import { mount } from 'cypress/react';
import 'cypress-axe';
import '../../src/styles/global.css'; // real tokens, or contrast is measured on defaults
// Rules whose correct answer depends on the rest of the page, not the component.
const PAGE_ONLY_RULES = {
bypass: { enabled: false }, // needs a page-level skip mechanism
'heading-order': { enabled: false }, // depends on the component's place in the outline
'landmark-unique': { enabled: false }, // depends on sibling components on the page
'target-size': { enabled: false }, // depends on real layout width and neighbours
};
const COMPONENT_TAGS = ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa', 'best-practice'];
Cypress.Commands.add('mountA11y', (jsx, options = {}) => {
cy.document({ log: false }).then((doc) => {
doc.documentElement.lang = 'en';
doc.title = 'Component harness';
const root = doc.querySelector('[data-cy-root]');
root.setAttribute('role', 'main'); // satisfies landmark-one-main and region
if (!doc.querySelector('[data-harness-heading]')) {
const header = doc.createElement('header');
header.setAttribute('data-harness-heading', '');
header.innerHTML = '<h1>Component harness</h1>';
doc.body.insertBefore(header, doc.body.firstChild);
}
});
return mount(jsx, options); // the component tree itself is untouched
});
Cypress.Commands.add('scanComponent', (context = '[data-cy-root]') => {
cy.window({ log: false }).then((win) => {
if (!win.axe) cy.injectAxe(); // once per document, not once per mount
});
cy.checkA11y(context, {
runOnly: { type: 'tag', values: COMPONENT_TAGS },
rules: PAGE_ONLY_RULES,
});
});
Scoping the scan to [data-cy-root] on top of the harness fix is belt and braces, and it earns its place: it keeps the synthetic header and h1 out of the reported node list, so a violation that does appear is always inside the component. The rule set and the scope are doing different jobs — the scope decides attribution, the rules object decides which questions get asked at all.
Validation
Two assertions prove the configuration, and both are needed. The first proves the harness fix worked: mount a component that is known to be clean and expect zero violations, which fails loudly if a future upgrade of the component-testing dev server changes the harness template. The second proves the rule set is still capable of failing: mount a deliberately broken fixture and assert that the spec throws. Without that second test, a typo in the rules object that disables everything looks exactly like a passing suite.
// src/components/OrderCard/OrderCard.cy.jsx
import OrderCard from './OrderCard';
const order = { id: '4182', total: 4290, currency: 'GBP', status: 'shipped' };
describe('OrderCard accessibility', () => {
it('has no violations in the augmented harness', () => {
cy.mountA11y(<OrderCard order={order} />);
cy.scanComponent(); // injects axe on this first scan only
});
it('reports the same clean result on a second mount without re-injecting', () => {
cy.mountA11y(<OrderCard order={order} onArchive={() => {}} />);
cy.window().then((win) => expect(win.axe, 'axe survived the remount').to.exist);
cy.scanComponent();
});
it('still fails on a real component-level defect', () => {
// An icon-only control with no accessible name: button-name, impact critical.
cy.mountA11y(<OrderCard order={order} archiveLabel="" />);
cy.on('fail', (err) => {
expect(err.message).to.contain('accessibility violation');
return false; // swallow the expected failure
});
cy.scanComponent();
});
});
Run one spec and read the counts rather than the colour. The two runs below are the same spec file against the same component, before and after the wrapper, and the targets are the tell: every finding in the first run points at an element the component does not own.
# Before: cy.mount() into the bare harness, scan scoped to the root.
npx cypress run --component --spec 'src/components/OrderCard/OrderCard.cy.jsx'
# 1) OrderCard accessibility has no violations in the augmented harness
# 6 accessibility violations were detected
# html-has-lang html
# html-lang-valid html
# document-title head > title
# landmark-one-main div[data-cy-root]
# region div[data-cy-root] > article
# page-has-heading-one html
# After: cy.mountA11y() supplies the document context, four rules disabled.
npx cypress run --component --spec 'src/components/OrderCard/OrderCard.cy.jsx'
# ✓ has no violations in the augmented harness (612ms)
# ✓ reports the same clean result on a second mount (188ms)
# ✓ still fails on a real component-level defect (241ms)
# button-name button.order-card__archive
Two numbers in that output are worth watching over time. The second test is faster than the first by roughly the cost of an axe injection, which is the evidence that the win.axe guard is working rather than silently re-evaluating the source. And the third test names exactly one rule: if the broken fixture ever reports zero, the rules object has disabled more than intended and the whole suite has become decorative.
Edge Cases and Conditional Guards
- Portalled content. A dialog, tooltip or toast that renders through a portal attaches to
document.body, not to[data-cy-root], so a scan scoped to the root misses it entirely. Scan with{ include: [['[data-cy-root]'], ['[data-portal-root]']] }for those specs, or assert the portal target exists before scanning so a moved portal container fails as a missing element instead of an empty scan. - Shadow roots. axe traverses open shadow roots automatically, so a design-system web component is scanned as expected — but
role="main"on the harness root does not cross the boundary, and a closed shadow root is opaque to every rule. Components with closed roots need their accessibility assertions written against the component’s own public API instead of a scan. - Async component state.
cy.mount()resolves as soon as the initial render commits, which for a component with auseEffectfetch means it resolves on the loading skeleton. Assert the settled state —cy.findByRole('table')or the absence of[aria-busy="true"]— beforecy.scanComponent(), because the scan runs once and will happily certify a spinner as accessible.
Pipeline Impact
Component specs run in their own Cypress project with a separate config, so give them a separate CI job rather than folding them into the e2e job. They need no application server, no seeded database and no authentication, which means they finish in a fraction of the time and can run on every push instead of only on pull requests. Two jobs also keep the exit codes attributable: a red component job points at a component, a red e2e job points at a page, and nobody has to open an artifact to work out which. A design system with hundreds of components will eventually outgrow one spec per component and want a catalogue-wide sweep instead, which is the approach taken in auditing a component library with Storybook and axe; the harness problem is identical there, because a Storybook story frame is just as bare as a Cypress mount root.
The disabled-rule list is the part of this configuration most likely to rot, so make it visible in review. Keep PAGE_ONLY_RULES in one exported constant, add a required comment for each entry naming where the rule is still enforced, and add the support file to the path filter that triggers the accessibility jobs. A new entry appearing in that object during a code review is the signal that someone is silencing a finding rather than fixing it, and that signal is worth more than any threshold. The same discipline applies to the shared rule overrides described in axe-core configuration and setup.
That gap is the reason to treat component scans as the fast inner loop and page scans as the gate. Composition failures — duplicate ids across repeated instances, heading order across sections, contrast against a panel the component knows nothing about, focus obscured by a sticky bar — are invisible to every component spec by construction, and several of them are exactly the criteria a page scan of an authenticated view catches first, as covered in scanning authenticated pages in Cypress a11y runs.
Common Pitfalls
- Wrapping the mounted component in a real
<main>element to satisfylandmark-one-main, which changes:first-childmatching and flex context and breaks the component’s own layout assertions in the same spec. - Injecting axe inside
beforeEachnext tocy.mount(), paying a full axe-core evaluation per test case and adding several seconds to every spec file with no benefit. - Disabling
regionandlandmark-one-mainglobally to silence the harness noise, which removes two of the most useful rules from the e2e page scan when the tag list is shared between projects. - Forgetting to import the application’s global stylesheet into the component support file, so
color-contrastis computed against unstyled browser defaults and reports findings that do not exist in production. - Scanning
[data-cy-root]while the component renders its dialog through a portal intodocument.body, producing a permanent zero-violation result for the part of the component most likely to be broken. - Treating a green component suite as page-level conformance evidence in an accessibility statement, when roughly half the rule catalogue never had a node to evaluate.
FAQ
Why not just pass a narrow context and leave the harness alone?
Because context filters the reported nodes, not the document the rule reasons about. html-has-lang inspects the root element no matter what context is passed, and region classifies the mounted root as content outside a landmark because in that document it is. Narrowing the context reduces the noise from some rules and does nothing for the document-scoped ones, which is why the harness itself has to change.
Does role="main" on the harness root risk hiding a real landmark problem in the component?
No, because a component should not be declaring a main landmark of its own — page-level landmark structure belongs to the layout, not to a card or a form. If a component genuinely renders its own main, the harness root’s role creates a second one and landmark-one-main fails, which is the correct outcome and worth investigating rather than suppressing. Components that render a nav or search landmark are unaffected.
Should component scans and page scans use the same tag list?
Share the tag list, differ on the rules object. Keeping the tags identical means both scopes claim the same conformance target and an axe upgrade lands in both at once; keeping the disabled rules separate means the four page-dependent rules are switched off in exactly one place, with a comment explaining where each is still enforced. A shared constant for tags and a per-project constant for rule overrides is the arrangement that survives contact with a design-system upgrade, and it fits the wrapper pattern described in the parent guide on Cypress a11y testing workflows.
Related
- Cypress a11y Testing Workflows — the parent guide covering suite-level setup, scoping and the violation reporter.
- Scanning Authenticated Pages in Cypress a11y Runs — the page-level counterpart, behind a login and across roles.
- Web Accessibility Testing Fundamentals & Tool Selection — the section comparing the scanning engines these specs sit on top of.