Back to Blog
March 21, 2026

Accessibility Testing Automation: Building WCAG Compliance Into Your CI Pipeline

Stop treating a11y as an afterthought — automate WCAG checks across Playwright, Cypress, and Selenium with axe-core and CI gates that fail fast.

Accessibility testing automation pipeline with axe-core scanning results across Playwright, Cypress, and Selenium

Why Accessibility Testing Belongs in Your Automation Suite

Most QA teams treat accessibility testing as a separate audit — something a consultant does once before launch. That approach fails. According to the WebAIM Million 2025 report, 95.9% of home pages had detectable WCAG failures, with an average of 56.8 errors per page. The problem is not awareness. The problem is that a11y checks happen too late in the development cycle to be actionable.

Automated accessibility testing changes the economics. When you embed axe-core into your existing Playwright, Cypress, or Selenium suites, every test run catches violations at the point where they are cheapest to fix — before the PR merges. The Deque 2025 State of Accessibility report found that teams with automated a11y gates in CI reduced their WCAG violation count by 68% within 6 months of adoption.

What you will learn: How to integrate axe-core with Playwright, Cypress, and Selenium. How to write custom accessibility assertions beyond axe defaults. How to build CI pipeline gates that enforce WCAG 2.2 AA compliance without slowing deployments.

How Does axe-core Integration Work Across Testing Frameworks?

Answer Capsule: axe-core injects a JavaScript accessibility engine into the browser context, scans the live DOM against WCAG rules, and returns structured violation data your test can assert on.

The core pattern is the same regardless of framework: navigate to a page, wait for it to stabilize, run axe, assert zero violations. The implementation details differ. Here is a side-by-side comparison.

FrameworkPackageScan MethodConfig Style
Playwright@axe-core/playwrightnew AxeBuilder({ page }).analyze()Builder pattern (chained methods)
Cypresscypress-axecy.checkA11y()Options object passed to checkA11y
Selenium@axe-core/webdriverjsnew AxeBuilder(driver).analyze()Builder pattern (similar to Playwright)

Playwright + axe-core: Production-Ready Setup

Playwright's axe integration is the most ergonomic of the three. Install the package and you can scan any page in under 10 lines of test code.

// Install: npm install -D @axe-core/playwright
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';

test('homepage meets WCAG 2.2 AA', async ({ page }) => {
  await page.goto('/');

  const results = await new AxeBuilder({ page })
    .withTags(['wcag2a', 'wcag2aa', 'wcag22aa'])
    .exclude('.third-party-widget') // skip elements you don't control
    .analyze();

  expect(results.violations).toEqual([]);
});

test('login form accessibility', async ({ page }) => {
  await page.goto('/login');
  // Wait for dynamic content to render
  await page.waitForSelector('[data-testid="login-form"]');

  const results = await new AxeBuilder({ page })
    .include('[data-testid="login-form"]') // scan only the form
    .withTags(['wcag2a', 'wcag2aa'])
    .analyze();

  // Log violations with context for debugging
  if (results.violations.length > 0) {
    console.log(
      'A11y violations:',
      results.violations.map(v => ({
        id: v.id,
        impact: v.impact,
        description: v.description,
        nodes: v.nodes.length,
      }))
    );
  }

  expect(results.violations).toEqual([]);
});

Gotcha: axe-core scans the DOM at a point in time. If your page has lazy-loaded content, modals, or tab panels, you need separate scans after triggering each state. A single scan of the initial page load misses content behind interactions.

Cypress + cypress-axe: Chained Accessibility Checks

Cypress uses the cypress-axe plugin, which wraps axe-core in Cypress-idiomatic commands. The setup requires injecting axe into the page before each scan.

// Install: npm install -D cypress-axe axe-core
// cypress/support/e2e.ts
import 'cypress-axe';

// cypress/e2e/accessibility.cy.ts
describe('Accessibility checks', () => {
  beforeEach(() => {
    cy.visit('/');
    cy.injectAxe(); // Must call before checkA11y
  });

  it('home page passes WCAG AA', () => {
    cy.checkA11y(null, {
      runOnly: {
        type: 'tag',
        values: ['wcag2a', 'wcag2aa'],
      },
    });
  });

  it('navigation menu is accessible when open', () => {
    cy.get('[data-testid="menu-toggle"]').click();
    cy.get('[data-testid="nav-menu"]').should('be.visible');

    // Scan only the open menu
    cy.checkA11y('[data-testid="nav-menu"]', {
      runOnly: {
        type: 'tag',
        values: ['wcag2a', 'wcag2aa'],
      },
    });
  });

  it('form validation errors are accessible', () => {
    cy.get('[data-testid="submit-btn"]').click();

    // After validation fires, check error messages are announced
    cy.checkA11y('[data-testid="signup-form"]', {
      rules: {
        'aria-allowed-attr': { enabled: true },
        'color-contrast': { enabled: true },
        'label': { enabled: true },
      },
    });
  });
});

Gotcha: cy.injectAxe() must be called after every cy.visit(). If you navigate to a new page mid-test, axe is no longer injected. Forgetting this produces silent failures — checkA11y passes because axe isn't running.

Selenium + @axe-core/webdriverjs: Legacy Suites Get A11y Too

Selenium teams are not left out. The @axe-core/webdriverjs package provides a builder API almost identical to Playwright's version.

// Install: npm install -D @axe-core/webdriverjs selenium-webdriver
import { Builder } from 'selenium-webdriver';
import AxeBuilder from '@axe-core/webdriverjs';

async function runA11yAudit() {
  const driver = await new Builder()
    .forBrowser('chrome')
    .build();

  try {
    await driver.get('https://your-app.com/dashboard');

    const results = await new AxeBuilder(driver)
      .withTags(['wcag2a', 'wcag2aa'])
      .exclude('.cookie-banner')
      .analyze();

    const critical = results.violations.filter(
      v => v.impact === 'critical' || v.impact === 'serious'
    );

    if (critical.length > 0) {
      console.error('Critical a11y violations found:');
      critical.forEach(v => {
        console.error(`  [${v.impact}] ${v.id}: ${v.help}`);
        v.nodes.forEach(n => {
          console.error(`    Target: ${n.target.join(', ')}`);
        });
      });
      throw new Error(`${critical.length} critical a11y violations`);
    }
  } finally {
    await driver.quit();
  }
}

runA11yAudit();

Beyond axe Defaults: Custom Accessibility Assertions

axe-core covers roughly 57% of WCAG 2.2 success criteria automatically. The remaining 43% requires human judgment or custom assertions. Here are three patterns that extend your coverage beyond what axe provides out of the box.

1. Focus trap validation for modals. axe checks that focusable elements exist, but it does not verify that focus is trapped inside a modal when open. Write a custom assertion:

// Playwright: Verify focus trap in modal
test('modal traps focus correctly', async ({ page }) => {
  await page.goto('/');
  await page.click('[data-testid="open-modal"]');

  const modal = page.locator('[role="dialog"]');
  await expect(modal).toBeVisible();

  // Tab through all focusable elements
  const focusableSelector =
    'a[href], button:not([disabled]), input:not([disabled]), ' +
    'select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';

  const focusableInModal = await modal.locator(focusableSelector).count();

  // Press Tab (focusableCount + 1) times — should cycle back to first element
  for (let i = 0; i <= focusableInModal; i++) {
    await page.keyboard.press('Tab');
  }

  // Focus should still be inside the modal
  const activeElement = await page.evaluate(() =>
    document.activeElement?.closest('[role="dialog"]') !== null
  );

  expect(activeElement).toBe(true);
});

2. Heading hierarchy validation. Screen readers rely on a logical heading structure. axe checks for heading presence but not order:

// Custom heading hierarchy check
test('heading levels follow logical order', async ({ page }) => {
  await page.goto('/');

  const headings = await page.evaluate(() => {
    const elements = document.querySelectorAll('h1, h2, h3, h4, h5, h6');
    return Array.from(elements).map(el => ({
      level: parseInt(el.tagName[1]),
      text: el.textContent?.trim().substring(0, 50),
    }));
  });

  // Verify no skipped levels (e.g., h1 -> h3 without h2)
  for (let i = 1; i < headings.length; i++) {
    const jump = headings[i].level - headings[i - 1].level;
    expect(
      jump,
      `Heading "${headings[i].text}" skips from h${headings[i - 1].level} to h${headings[i].level}`
    ).toBeLessThanOrEqual(1);
  }

  // Verify exactly one h1
  const h1Count = headings.filter(h => h.level === 1).length;
  expect(h1Count, 'Page should have exactly one h1').toBe(1);
});

3. Color contrast for dynamic states. axe checks contrast at scan time, but hover, focus, and error states often introduce low-contrast combinations:

// Scan after triggering interactive states
test('error state maintains color contrast', async ({ page }) => {
  await page.goto('/signup');

  // Trigger validation errors
  await page.click('[data-testid="submit"]');
  await page.waitForSelector('.error-message');

  // Now scan — axe will evaluate the error-state DOM
  const results = await new AxeBuilder({ page })
    .include('form')
    .withRules(['color-contrast'])
    .analyze();

  expect(results.violations).toEqual([]);
});

What Should You Automate vs. Leave to Human Review?

Answer Capsule: Automate structural checks (labels, roles, contrast, heading order). Leave cognitive and contextual judgments (alt text quality, reading flow, content clarity) to human auditors.

WCAG CriteriaAutomatable?Tool
1.1.1 Non-text Content (alt text exists)Yesaxe-core
1.1.1 Non-text Content (alt text is meaningful)NoManual review
1.4.3 Contrast (Minimum)Yesaxe-core
2.1.1 KeyboardPartialCustom assertions + manual
2.4.6 Headings and LabelsPartialaxe + custom heading check
3.1.1 Language of PageYesaxe-core
4.1.2 Name, Role, ValueYesaxe-core

Building CI Pipeline Gates: Fail Fast on A11y Violations

The real power of automated accessibility testing is the CI gate. Here is a pattern that works across GitHub Actions, GitLab CI, and Jenkins: run your a11y test suite as a dedicated stage, fail the pipeline on critical and serious violations, and generate an HTML report for developers to triage.

# .github/workflows/a11y.yml
name: Accessibility Gate

on: [pull_request]

jobs:
  a11y:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: 20

      - run: npm ci

      - name: Start app
        run: npm run dev &
        env:
          PORT: 3000

      - name: Wait for app
        run: npx wait-on http://localhost:3000 --timeout 30000

      - name: Run accessibility tests
        run: npx playwright test --project=a11y --reporter=html
        continue-on-error: false

      - name: Upload a11y report
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: a11y-report
          path: playwright-report/

Create a dedicated Playwright project for accessibility tests so they run independently from your functional suite:

// playwright.config.ts
import { defineConfig } from '@playwright/test';

export default defineConfig({
  projects: [
    {
      name: 'functional',
      testDir: './tests/e2e',
    },
    {
      name: 'a11y',
      testDir: './tests/a11y',
      use: {
        // A11y tests only need one browser
        browserName: 'chromium',
      },
    },
  ],
});

Edge case: Dynamic content like toast notifications, loading spinners, and skeleton screens can cause false positives. Use axe's exclude option to skip transient elements, or wait for them to disappear before scanning. A loading spinner with low contrast is not an accessibility violation worth blocking a deploy.

Measuring Progress: Accessibility Dashboards and Trend Tracking

Once your CI gate is in place, you need visibility into trends. Are violations decreasing over time? Which WCAG criteria fail most often? Build a simple JSON report that feeds into your existing dashboards:

// tests/a11y/reporter.ts — Custom a11y metrics collector
import fs from 'fs';
import type { AxeResults } from 'axe-core';

interface A11yMetrics {
  timestamp: string;
  totalViolations: number;
  bySeverity: Record<string, number>;
  byRule: Record<string, number>;
  pagesScanned: number;
}

export function collectMetrics(
  results: AxeResults[],
  outputPath: string
): A11yMetrics {
  const metrics: A11yMetrics = {
    timestamp: new Date().toISOString(),
    totalViolations: 0,
    bySeverity: { critical: 0, serious: 0, moderate: 0, minor: 0 },
    byRule: {},
    pagesScanned: results.length,
  };

  for (const result of results) {
    for (const violation of result.violations) {
      metrics.totalViolations += violation.nodes.length;
      metrics.bySeverity[violation.impact ?? 'minor'] += violation.nodes.length;
      metrics.byRule[violation.id] =
        (metrics.byRule[violation.id] ?? 0) + violation.nodes.length;
    }
  }

  fs.writeFileSync(outputPath, JSON.stringify(metrics, null, 2));
  return metrics;
}

Troubleshooting Common A11y Test Failures

When your a11y tests start failing, here are the most common issues and how to resolve them:

SymptomRoot CauseFix
color-contrast failures on hidden elementsaxe scans elements with visibility: hidden that inherit low-contrast colorsExclude hidden containers or fix their inherited styles
Tests pass locally, fail in CIPage not fully loaded before axe scan; CI is slowerAdd explicit waitForSelector or waitForLoadState('networkidle')
Hundreds of violations from third-party widgetsChat widgets, analytics, embedded iframesUse .exclude('.third-party-container') — you cannot fix code you do not own
aria-required-parent false positiveFramework renders wrapper divs that break ARIA hierarchyDisable specific rule with .disableRules(['aria-required-parent']) after confirming it is a false positive
0 violations but page is clearly inaccessibleAutomated tools only cover a fraction of WCAG criteria; axe cannot detect keyboard traps or confusing UXAdd custom assertions for focus management, heading order, and keyboard navigation

Pro tip: When you encounter a false positive, do not just suppress the rule globally. Use .exclude() on the specific element or .disableRules() in a targeted test. Add a comment explaining why the rule is suppressed so future developers do not re-enable it and wonder why it fails.

Key Takeaways

  • axe-core integrates with Playwright, Cypress, and Selenium in under 15 minutes. There is no excuse to skip automated a11y testing.
  • Automated scans leave nearly half of WCAG criteria uncovered. Supplement with custom assertions for focus traps, heading hierarchy, and interactive state contrast.
  • Start your CI gate with critical and serious violations only. Tighten gradually as your baseline improves.
  • Track a11y metrics over time. A dashboard showing violation trends per sprint is more motivating than a one-time audit report.
  • Exclude third-party widgets you cannot control. Focus your gate on code your team owns.

Ready to strengthen your test automation?

Desplega.ai helps QA teams build robust test automation frameworks with accessibility baked in from the start.

Start Your Testing Transformation

Frequently Asked Questions

Can axe-core catch all WCAG violations automatically?

No. axe-core catches roughly 57% of WCAG issues automatically. Color contrast, keyboard navigation, and screen reader flows require manual review alongside axe scans.

How do I integrate accessibility testing into an existing Playwright suite?

Install @axe-core/playwright, import it in your test file, call new AxeBuilder({ page }).analyze() after page loads, then assert results.violations.length === 0. Takes under 5 minutes for a first scan.

Should I fail the CI build on every accessibility violation?

Start by failing only on critical and serious violations. Use axe tags to filter by WCAG level (wcag2a, wcag2aa). Once your baseline is clean, gradually tighten the gate to include moderate issues.

What is the performance impact of running axe-core in CI?

An axe-core full-page scan typically adds 200-800ms per page. For a suite of 50 pages, expect 10-40 seconds total. Run a11y checks on critical user flows only to keep pipeline times under control.

Which WCAG level should I target — A, AA, or AAA?

Target WCAG 2.2 Level AA. It covers the most impactful criteria (color contrast, keyboard access, form labels) and is the legal standard in the EU, US Section 508, and most accessibility regulations worldwide.