Automating Accessibility Testing with axe-core
AI generated
PASS
expect()
Testing · Accessibility · axe-core · E2E
Automating Accessibility Testing with axe-core
Automated A11y Checks in Cypress and Playwright

Automated accessibility testing with axe-core covers only about 30 to 40 percent of all WCAG criteria, but those categories, things like color contrast, missing alt text, and ARIA errors, can be reliably integrated into any Cypress or Playwright pipeline. This article shows how to configure thresholds, block CI builds only on new violations, and catch typical Magento- and Hyvä-specific barriers early.

14 min. read axe-core · Cypress · Playwright WCAG 2.1 · CI/CD · Magento 2

1. Why automated accessibility testing is essential in the E2E workflow

Accessibility is no longer optional: with the German Accessibility Strengthening Act (BFSG) and the European Accessibility Act, digital stores in the EU have been legally required to offer accessible online services since 2025. Manual audits by external testing bodies are expensive and usually happen only shortly before a release, when fixes are already costly. Teams that instead integrate accessibility checks directly into their existing E2E test suite catch regressions just as early as functional bugs, namely on every pull request.

axe-core is the open-source engine built by Deque Systems that also underpins many other tools like Lighthouse and WAVE. As an npm library, it plugs directly into Cypress or Playwright and inspects the actual rendered DOM at runtime, not just static HTML. That distinction matters for modern frontends: an Alpine.js dropdown or a dynamically loaded form often looks different in the source code than in the final DOM, and only a runtime check captures the actual state a user experiences.

2. What axe-core detects automatically, and where manual review is still required

According to research by Deque, roughly 30 to 40 percent of all WCAG success criteria can realistically be checked automatically and reliably. These include insufficient color contrast (WCAG 1.4.3), missing alt attributes on images (1.1.1), form fields without an associated label (1.3.1 and 4.1.2), broken or duplicate ARIA roles and attributes, an illogical heading hierarchy, and a missing or empty <title>. These categories are programmatically testable because they are structural properties that can be unambiguously detected in the DOM.

What automated tools can't detect are aspects that require human judgment: whether the tab order during keyboard navigation logically follows the visual flow, whether a screen reader actually reads a page in an understandable way, whether an existing alt text meaningfully describes the image content instead of merely existing as a formality, or whether focus management inside a modal dialog works correctly. Automated tests are a necessary but not a sufficient complement to planned manual reviews here.

3. Cypress integration with cypress-axe

The cypress-axe package wraps axe-core as a Cypress command and fits seamlessly into an existing E2E suite. After every cy.visit(), you need to call cy.injectAxe() to inject the axe-core runtime into the page before cy.checkA11y() runs the actual check. It's important to run checks not just once per page but after every relevant interaction, such as opening a dropdown, a form validation, or a modal, since those dynamic states are exactly where most violations turn up.

A second important point: if the DOM gets fully re-rendered by a framework like Alpine.js, cy.injectAxe() must be called again, since the previous injection would otherwise be lost. The optional fourth parameter of cy.checkA11y() controls whether detected violations fail the test immediately or are just logged to the console first, which is especially useful during the rollout phase.


// cypress/e2e/accessibility/checkout.cy.js
describe('Checkout accessibility', () => {
  beforeEach(() => {
    cy.visit('/checkout');
    cy.injectAxe(); // inject axe-core into the page after each navigation
  });

  it('has no serious or critical violations on the shipping step', () => {
    cy.checkA11y('#checkout-container', {
      runOnly: ['wcag2a', 'wcag2aa', 'wcag21aa'],
    }, null, true); // skipFailures=true, log first before enforcing hard failures
  });

  it('has no violations after opening the payment method dropdown', () => {
    cy.get('[data-test="payment-method-toggle"]').click();
    cy.injectAxe(); // re-inject if Alpine.js replaced the surrounding DOM
    cy.checkA11y('[data-test="payment-methods"]');
  });
});

4. Playwright integration with @axe-core/playwright

For Playwright, @axe-core/playwright provides a fluent API called AxeBuilder that attaches directly to an open page instance. After page.goto(), new AxeBuilder({ page }).analyze() is enough to trigger a complete check. Since Playwright drives real browser engines, Chromium, Firefox, and WebKit, accessibility checks can additionally run cross-browser, which matters because ARIA support and focus behavior can genuinely differ between engines.

withTags() filters the WCAG categories being checked, exclude() carves out uncontrollable third-party code like chat widgets, and include() limits the check to a specific container. The full results can be embedded directly into the Playwright HTML report via testInfo.attach(), so reviewers can see violations, including the affected selector and help URL, without a separate tool.


// tests/accessibility/product-page.spec.ts
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';

test('product page has no critical accessibility violations', async ({ page }) => {
  await page.goto('/catalog/product/view/id/123');

  const results = await new AxeBuilder({ page })
    .withTags(['wcag2a', 'wcag2aa', 'wcag21aa'])
    .exclude('#third-party-chat-widget') // exclude uncontrolled third-party markup
    .analyze();

  // Attach the full report to the Playwright HTML report for review
  await test.info().attach('axe-results', {
    body: JSON.stringify(results, null, 2),
    contentType: 'application/json',
  });

  const critical = results.violations.filter(v => v.impact === 'critical' || v.impact === 'serious');
  expect(critical, `Found ${critical.length} serious/critical violations`).toHaveLength(0);
});

5. Severity thresholds and rule configuration

Every axe-core violation carries an impact level: minor, moderate, serious, or critical. Not every team wants to block the build over a minor contrast error on a rarely visited subpage. The rules configuration lets you enable or disable individual rules, while runOnly with tags like wcag2a, wcag2aa, wcag21aa, or best-practice lets you tailor the rule set precisely to your own compliance goals.

A pragmatic compromise that has proven itself in practice: the CI build only fails on serious- and critical-level violations, while moderate and minor ones are logged and collected in a dashboard without blocking the build. That preserves team velocity without overlooking the most serious barriers.


{
  "rules": {
    "color-contrast": { "enabled": true },
    "landmark-one-main": { "enabled": false },
    "region": { "enabled": false }
  },
  "runOnly": {
    "type": "tag",
    "values": ["wcag2a", "wcag2aa", "wcag21aa", "best-practice"]
  },
  "resultTypes": ["violations"],
  "reporter": "v2"
}

6. CI pipeline: blocking only new violations, not legacy debt

The biggest obstacle to introducing automated accessibility testing: mature applications often carry hundreds of existing violations. An immediate hard gate check would break every build and would more likely tempt the team to disable the check entirely rather than take it seriously. The proven approach instead is a baseline snapshot: the current state of violations gets committed to the repository as a JSON file, and CI compares only the diff against that baseline on every build.

New violations not present in the baseline block the merge immediately. Already-known legacy violations are tracked separately and prioritized in their own tickets, without disrupting the day-to-day development flow. Each rule ID and affected selector serves as a fingerprint for the comparison, so individual fixes can also be removed from the baseline once they're resolved.


# .github/workflows/accessibility.yml
name: Accessibility Regression Check
on: [pull_request]

jobs:
  axe-baseline-diff:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npm run build
      - name: Run axe-core against staging pages
        run: npm run test:a11y -- --json > current-violations.json
      - name: Compare against committed baseline
        run: |
          node scripts/diff-a11y-baseline.js \
            --baseline .a11y-baseline.json \
            --current current-violations.json \
            --fail-on-new
      - name: Upload violation report
        if: failure()
        uses: actions/upload-artifact@v4
        with:
          name: accessibility-report
          path: current-violations.json

7. Magento- and Hyvä-specific accessibility issues

In Hyvä stores, axe-core tends to surface recurring patterns: insufficient color contrast from Tailwind utility classes like text-gray-400 on a white background, which often falls below the required 4.5:1 contrast ratio. Just as common: icon-only buttons such as the mini-cart icon, the search toggle, or the wishlist heart, without an aria-label. Sighted users can infer the function from the icon, but for screen reader users a button without an accessible label remains a meaningless "button, unlabeled".

An Alpine.js-specific issue involves x-show: the directive only hides elements via CSS display: none, while the content, if timing is misconfigured or during transitions, can remain present in the accessibility tree and sometimes even focusable. x-cloak combined with x-if, which removes elements from the DOM entirely instead of just hiding them, reliably avoids this problem. In checkout, form fields without a <label for> association are also a perennial issue, especially with custom-styled select components where a plain placeholder is mistakenly treated as sufficient.


<!-- Before: icon-only button without aria-label, x-show leaves content in the a11y tree -->
<button @click="miniCartOpen = !miniCartOpen">
    <svg class="w-6 h-6" ...></svg>
</button>
<div x-show="miniCartOpen" class="absolute right-0 ...">
    ...
</div>

<!-- After: accessible label, x-cloak avoids the flash, x-if removes hidden content from the DOM -->
<button
    @click="miniCartOpen = !miniCartOpen"
    :aria-expanded="miniCartOpen.toString()"
    aria-label="{{ __('Open mini cart') }}"
>
    <svg class="w-6 h-6" aria-hidden="true" ...></svg>
</button>
<template x-if="miniCartOpen">
    <div x-cloak class="absolute right-0 ..." role="dialog" aria-label="{{ __('Mini cart') }}">
        ...
    </div>
</template>

<!-- Checkout input without an associated label triggers axe-core's "label" rule -->
<input type="text" id="street_1" name="street[0]" placeholder="{{ __('Street Address') }}">
<!-- Fix: explicit label, a placeholder alone is not an accessible name -->
<label for="street_1" class="sr-only">{{ __('Street Address') }}</label>
<input type="text" id="street_1" name="street[0]">

8. Complementing manual tests: keyboard, screen reader, reading order

A keyboard-only pass is one of the cheapest and most effective manual tests: walk through the entire checkout flow using only Tab, Shift+Tab, Enter, and Escape. Every interactive element must be reachable and receive a visible focus ring, no focus trap should keep the user stuck inside a modal, and the tab order must match the page's visual flow.

A screen reader spot check with NVDA on Windows or VoiceOver on macOS and iOS on critical pages like the product detail page and checkout reveals whether live announcements, for example on cart updates (aria-live), are actually read aloud and whether the reading order matches the visual layout, something axe-core simply cannot evaluate by design. These checks can't be fully automated, but they should be a fixed part of sprint reviews or scheduled as a quarterly audit, rather than only being addressed reactively when complaints come in.

9. Testing methods compared

Automated and manual methods cover different areas and complement rather than replace each other. The table below shows what each method is best suited for and where its limits lie.

Testing method Automatable Typical shortcoming Recommended use
axe-core (Cypress/Playwright) Yes, fully automated Detects only about 30-40% of WCAG criteria On every pull request / build
Lighthouse Accessibility Audit Partially automated Only a single-page snapshot, no interaction states Quick spot checks alongside axe-core
Manual keyboard test No, manual Time-consuming, not CI-suitable Before every major release
Screen reader test (NVDA/VoiceOver) No, manual Requires trained testers Quarterly on critical flows
Visual focus review No, manual Easily overlooked with custom components For every new UI pattern
Contrast checker browser plugin Partially automated Only checks the visible viewport Complements the axe-core contrast rule

In practice, automated and manual methods complement each other: axe-core catches structural errors on every commit, while scheduled manual audits cover the remaining 60 to 70 percent of WCAG criteria that require context and human judgment and simply cannot be captured in a CI pipeline.

Mironsoft

Accessibility testing, axe-core integration, and Hyvä a11y optimization for Magento stores

Ready to roll out automated accessibility testing professionally?

We integrate axe-core into your Cypress or Playwright suite, configure sensible thresholds, and set up a baseline that blocks new violations without slowing the build down with legacy debt.

axe-core CI integration

Cypress or Playwright setup with sensible severity thresholds

Magento/Hyvä a11y audit

Targeted checks of color contrast, icon buttons, and Alpine.js states

Monitoring & baseline setup

Baseline diffing in the CI/CD pipeline without legacy debt blocking the build

10. Summary

Accessibility testing with axe-core solves a clearly scoped but important part of the problem: it reliably catches structural WCAG violations like missing color contrast, missing labels, and ARIA errors on every commit, instead of discovering them shortly before a release or, worse, only through user complaints. Integration via cypress-axe or @axe-core/playwright takes just a few lines of code, but it only delivers real value once you pair it with sensibly configured severity thresholds and a baseline strategy that blocks new violations without stumbling over existing legacy debt.

Managing expectations realistically is key: axe-core covers roughly 30 to 40 percent of WCAG criteria, and the rest, things like keyboard navigation, screen reader comprehensibility, and a sensible reading order, remains the job of scheduled manual audits. In Magento and Hyvä stores, automated checks most often run into Tailwind contrast errors, missing labels on icon buttons, and Alpine.js states that stay in the accessibility tree despite being visually hidden.

Accessibility Testing with axe-core - The Essentials at a Glance

Assess coverage realistically

axe-core automatically finds about 30-40% of WCAG criteria; the rest needs manual review.

CI without legacy blockers

Baseline diffing blocks only new violations; known legacy issues get resolved separately.

Magento/Hyvä pitfalls

Specifically check Tailwind contrast, icon buttons without aria-label, and x-show states.

Schedule manual tests

Anchor keyboard and screen reader checks firmly in sprint reviews or quarterly audits.

11. FAQ: Automating Accessibility Testing with axe-core

1What is axe-core and how does it differ from other accessibility tools?
Open-source testing engine from Deque Systems, checks the rendered DOM at runtime. Underpins Lighthouse and WAVE, but can be integrated directly as an npm library into Cypress or Playwright.
2What share of WCAG criteria can be tested automatically with axe-core?
About 30 to 40 percent, including color contrast, missing alt text, missing form labels, and ARIA errors. The rest requires manual review.
3How do I integrate axe-core into Cypress?
With cypress-axe: cy.injectAxe() after every cy.visit(), then cy.checkA11y() after every relevant UI state such as opened dropdowns or modals.
4How do I integrate axe-core into Playwright?
With @axe-core/playwright and the AxeBuilder class: new AxeBuilder({ page }).analyze() after page.goto(). withTags() and exclude() control the scope of the check.
5How do I stop axe-core from blocking the CI build over existing legacy debt?
With a baseline snapshot: CI only checks the diff against the committed baseline. New violations block the merge; legacy violations get resolved separately.
6What severity levels (impact levels) does axe-core use?
minor, moderate, serious, and critical. Common practice: the build only fails on serious/critical, while moderate/minor are logged instead of blocking.
7What Magento/Hyvä-specific accessibility issues does axe-core commonly find?
Insufficient color contrast with Tailwind classes, icon-only buttons like the mini cart without an aria-label, and checkout form fields without an associated label.
8Why is manual testing still necessary despite axe-core?
axe-core can't evaluate subjective aspects like meaningful alt text, a logical keyboard order, or focus management, that requires human judgment.
9Can axe-core also check dynamic Alpine.js states like x-show?
Yes, since axe-core checks the DOM as rendered at runtime. Re-trigger the check after every state change, and replace x-show with x-if where needed.
10Does a passing axe-core check equal legal compliance with BFSG/WCAG?
No. It covers only part of the WCAG criteria and does not replace a full manual conformance review required for a legally sound BFSG declaration.