Why automate accessibility testing?

๐Ÿ’ก The earlier you catch bugs, the cheaper they are to fix

Accessibility issues found in production cost 10-100x more to fix than those caught during development. Automated testing catches ~30-40% of issues instantly, on every commit.

Benefits of CI/CD integration

The accessibility testing pyramid

          /\
         /  \
        / ๐Ÿ‘ค \     Manual testing (10%)
       /      \    - Screen reader, keyboard
      /--------\   - Usability studies
     /   ๐Ÿ”    \   Integration tests (30%)
    /           \  - axe-core in e2e tests
   /-------------\ - Storybook a11y addon
  /     โšก       \  Unit tests (60%)
 /                \ - Component-level axe
/------------------\ - eslint-plugin-jsx-a11y
      

Automate what you can (bottom), manually test what you must (top).

axe-core: The industry standard

axe-core by Deque is the most widely-used accessibility testing engine. It powers most automated tools.

What it catches

What it can't catch

GitHub Actions setup

Basic workflow with axe

# .github/workflows/accessibility.yml
name: Accessibility Tests

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  a11y-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      
      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
      
      - name: Install dependencies
        run: npm ci
      
      - name: Build project
        run: npm run build
      
      - name: Start server
        run: npm run start &
        
      - name: Wait for server
        run: npx wait-on http://localhost:3000
      
      - name: Run accessibility tests
        run: npm run test:a11y
      
      - name: Upload results
        uses: actions/upload-artifact@v4
        if: always()
        with:
          name: a11y-results
          path: a11y-results/

Using pa11y-ci

# package.json
{
  "scripts": {
    "test:a11y": "pa11y-ci"
  },
  "devDependencies": {
    "pa11y-ci": "^3.0.0"
  }
}

# .pa11yci.json
{
  "defaults": {
    "standard": "WCAG2AA",
    "runners": ["axe"],
    "chromeLaunchConfig": {
      "args": ["--no-sandbox"]
    }
  },
  "urls": [
    "http://localhost:3000/",
    "http://localhost:3000/about",
    "http://localhost:3000/contact",
    "http://localhost:3000/products"
  ]
}

Jest + React Testing Library

Setup jest-axe

npm install --save-dev jest-axe @testing-library/react

Component test example

import { render } from '@testing-library/react';
import { axe, toHaveNoViolations } from 'jest-axe';
import Button from './Button';

expect.extend(toHaveNoViolations);

describe('Button', () => {
  it('should have no accessibility violations', async () => {
    const { container } = render(
      <Button onClick={() => {}}>Click me</Button>
    );
    
    const results = await axe(container);
    expect(results).toHaveNoViolations();
  });

  it('should have no violations when disabled', async () => {
    const { container } = render(
      <Button disabled>Disabled button</Button>
    );
    
    const results = await axe(container);
    expect(results).toHaveNoViolations();
  });
});

Testing specific rules

it('should have sufficient color contrast', async () => {
  const { container } = render(<Alert type="warning">Warning!</Alert>);
  
  const results = await axe(container, {
    rules: {
      'color-contrast': { enabled: true }
    }
  });
  
  expect(results).toHaveNoViolations();
});

Cypress integration

Install cypress-axe

npm install --save-dev cypress-axe axe-core

Setup in cypress/support/e2e.js

import 'cypress-axe';

// Custom command to check accessibility
Cypress.Commands.add('checkA11y', (context, options) => {
  cy.injectAxe();
  cy.checkA11y(context, options, (violations) => {
    // Log violations to console with details
    violations.forEach((violation) => {
      const nodes = violation.nodes.map(n => n.target).join(', ');
      cy.log(`${violation.impact}: ${violation.description} (${nodes})`);
    });
  });
});

E2E test example

describe('Homepage accessibility', () => {
  beforeEach(() => {
    cy.visit('/');
    cy.injectAxe();
  });

  it('should have no accessibility violations on load', () => {
    cy.checkA11y();
  });

  it('should have no violations after opening modal', () => {
    cy.get('[data-cy="open-modal"]').click();
    cy.get('[data-cy="modal"]').should('be.visible');
    cy.checkA11y('[data-cy="modal"]');
  });

  it('should have no violations in dark mode', () => {
    cy.get('[data-cy="theme-toggle"]').click();
    cy.checkA11y(null, {
      rules: {
        'color-contrast': { enabled: true }
      }
    });
  });
});

Playwright integration

Install @axe-core/playwright

npm install --save-dev @axe-core/playwright

Test example

import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';

test.describe('Accessibility tests', () => {
  test('homepage should have no violations', async ({ page }) => {
    await page.goto('/');
    
    const results = await new AxeBuilder({ page })
      .withTags(['wcag2a', 'wcag2aa'])
      .analyze();
    
    expect(results.violations).toEqual([]);
  });

  test('form page should be accessible', async ({ page }) => {
    await page.goto('/contact');
    
    // Fill form to test all states
    await page.fill('#name', 'Test User');
    await page.fill('#email', 'test@example.com');
    
    const results = await new AxeBuilder({ page }).analyze();
    expect(results.violations).toEqual([]);
  });
});

Storybook a11y addon

Test components in isolation during development.

Install

npm install --save-dev @storybook/addon-a11y

Configure in .storybook/main.js

module.exports = {
  addons: [
    '@storybook/addon-a11y'
  ]
};

Run in CI

# Using storybook test runner
npm install --save-dev @storybook/test-runner axe-playwright

# package.json
{
  "scripts": {
    "test:storybook": "test-storybook"
  }
}

ESLint for JSX accessibility

Install eslint-plugin-jsx-a11y

npm install --save-dev eslint-plugin-jsx-a11y

ESLint config

// .eslintrc.js
module.exports = {
  plugins: ['jsx-a11y'],
  extends: ['plugin:jsx-a11y/recommended'],
  rules: {
    // Enforce alt text
    'jsx-a11y/alt-text': 'error',
    
    // Require labels for inputs
    'jsx-a11y/label-has-associated-control': 'error',
    
    // No onClick on non-interactive elements
    'jsx-a11y/click-events-have-key-events': 'error',
    'jsx-a11y/no-static-element-interactions': 'error',
    
    // Enforce heading order
    'jsx-a11y/heading-has-content': 'error'
  }
};

This catches issues at write-time in the IDE, before code is even committed.

Reporting and tracking

Generate HTML reports

// Custom reporter for pa11y
const reporter = {
  results: (results) => {
    const html = generateHTMLReport(results);
    fs.writeFileSync('a11y-report.html', html);
  }
};

// Or use axe-html-reporter
const { createHtmlReport } = require('axe-html-reporter');
createHtmlReport({ results });

Track metrics over time

Dashboard integration

Send metrics to your monitoring dashboard:

// After running axe
const metrics = {
  total_violations: results.violations.length,
  critical: results.violations.filter(v => v.impact === 'critical').length,
  serious: results.violations.filter(v => v.impact === 'serious').length,
  pages_tested: urls.length
};

// Send to your metrics system
await sendToDatadog(metrics);
await sendToGrafana(metrics);

Best practices

Do

Don't

Progressive enforcement

// Start by warning only
cy.checkA11y(null, null, null, { skipFailures: true });

// Then fail on critical only
cy.checkA11y(null, {
  rules: {
    'critical': { enabled: true }
  }
});

// Finally, fail on all WCAG 2.1 AA
cy.checkA11y(null, {
  runOnly: {
    type: 'tag',
    values: ['wcag2a', 'wcag2aa', 'wcag21aa']
  }
});

Resources