Back to articles

Accessibility Testing & CI Integration: Catching A11y Issues Before They Ship

Automate Accessibility Audits with axe-core, Playwright, and GitHub Actions

July 14, 2025
Updated July 14, 2025
A futuristic assembly line where glowing web page mockups pass through scanning gates that highlight accessibility elements like form labels and contrast ratios, color-coded green or amber, in a sleek dark industrial space with teal and violet lighting
accessibility
testing
web development
ci-cd
13 min read

You’ve built accessible components. Your forms have proper labels, your keyboard navigation works, and your contrast ratios pass. Now comes the question nobody wants to answer: how do you make sure it stays that way?

In part 1, we covered ARIA, keyboard navigation, and visual design fundamentals. In part 2, we tackled forms. This final part is about making accessibility a first-class citizen in your development workflow — not something you audit once a quarter and forget about.

important

This article will focus on automated testing and CI integration. Automated tools catch around 30-50% of WCAG issues. Manual testing with real assistive technologies is still essential — but automation prevents the regressions that undo all your hard work.

Why Automate Accessibility Testing?

Here’s a pattern you might have seen: Does a big accessibility push, fixes hundreds of issues, ships it, and six months later..

Half those fixes have regressed. New features got added, someone replaced a semantic button with a styled div, placeholder-only labels crept back in.

Accessibility regressions happen because they’re invisible to most developers during regular testing. You don’t notice a missing aria-label the way you notice a broken layout. By the time someone flags it, the damage is spread across dozens of components.

Automated testing catches the low-hanging fruit - missing alt text, broken label associations, contrast failures, invalid ARIA attributes before they ever hit production. It’s not a replacement for manual testing, but it’s a safety net that prevents the most common mistakes from slipping through.

The Testing Pyramid for Accessibility

Think of a11y testing in layers, similar to the traditional testing pyramid:

Static analysis (fastest, cheapest): Linting rules that flag issues in your code before it even runs. Things like missing alt attributes, invalid ARIA roles, or form inputs without labels.

Component-level tests (fast, targeted): Unit and integration tests that render components and check their accessibility tree. This is where axe-core shines.

End-to-end tests (slower, comprehensive): Full browser tests that navigate your app like a real user and audit entire pages. Playwright and Cypress both support this well.

Manual audits (slowest, most thorough): Screen reader testing, keyboard walkthroughs, and user testing with people who rely on assistive technology. No automation replaces this.

The goal is to catch as much as possible in the lower layers so manual audits can focus on the nuanced stuff. Like whether your tab order actually makes sense, not just whether it technically works.

Static Analysis with eslint-plugin-jsx-a11y

If you’re writing React (or any JSX-based framework), this plugin catches accessibility issues at the linting stage before your code even compiles.

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

Add it to your ESLint config:

{
  "extends": ["plugin:jsx-a11y/recommended"],
  "plugins": ["jsx-a11y"]
}

This catches things like:

  • <img> without alt
  • Click handlers on non-interactive elements without keyboard support
  • Missing htmlFor on labels
  • Invalid ARIA attributes or roles
// ❌ ESLint will flag this
<div onClick={handleClick}>Click me</div>

// ✅ This passes
<button onClick={handleClick}>Click me</button>

// ❌ Missing alt
<img src="photo.jpg" />

// ✅ Descriptive alt
<img src="photo.jpg" alt="Mountain landscape at sunset" />
tip

If you’re using TypeScript with strict mode (which you should be), pairing eslint-plugin-jsx-a11y with your type checking catches a surprising number of issues before you even open a browser.

The recommended preset is sensible for most projects. There’s also a strict preset that enforces additional rules, but I’d start with recommended and tighten from there based on what your team actually needs.

Component Testing with axe-core

axe-core is the engine behind most automated accessibility testing tools. It runs a set of rules against a DOM tree and reports violations, grouped by impact level (critical, serious, moderate, minor).

Setting Up with Jest and React Testing Library

npm install --save-dev jest-axe @axe-core/react

Here’s the basic pattern:

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

expect.extend(toHaveNoViolations);

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

That’s it. One assertion that checks your rendered component against dozens of WCAG rules.

Making It More Useful

The basic setup catches violations, but the error messages can be dense. Here’s a helper that makes failures more actionable:

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

expect.extend(toHaveNoViolations);

async function checkA11y(ui: React.ReactElement) {
  const { container } = render(ui);
  const results = await axe(container);
  expect(results).toHaveNoViolations();
}

// Usage in tests
it('navigation is accessible', async () => {
  await checkA11y(<Navigation items={mockItems} />);
});

it('search bar is accessible', async () => {
  await checkA11y(<SearchBar onSearch={jest.fn()} />);
});
important

axe-core tests are fast. Usually under 100ms per component. There’s no good reason to skip them. Add one to every component test file and make it a team convention.

Testing Different States

Components often have multiple states, and each one needs to be accessible:

describe('Modal', () => {
  it('is accessible when open', async () => {
    const { container } = render(
      <Modal isOpen={true} onClose={jest.fn()} title="Confirm Action">
        <p>Are you sure?</p>
      </Modal>
    );
    const results = await axe(container);
    expect(results).toHaveNoViolations();
  });

  it('manages focus correctly when opened', () => {
    const { getByRole } = render(
      <Modal isOpen={true} onClose={jest.fn()} title="Confirm Action">
        <p>Are you sure?</p>
      </Modal>
    );
    // Focus should move to the modal or its first focusable element
    expect(getByRole('dialog')).toHaveFocus();
  });
});
describe('Accordion', () => {
  it('is accessible when collapsed', async () => {
    const { container } = render(
      <Accordion title="Details" expanded={false}>
        <p>Hidden content</p>
      </Accordion>
    );
    const results = await axe(container);
    expect(results).toHaveNoViolations();
  });

  it('is accessible when expanded', async () => {
    const { container } = render(
      <Accordion title="Details" expanded={true}>
        <p>Visible content</p>
      </Accordion>
    );
    const results = await axe(container);
    expect(results).toHaveNoViolations();
  });

  it('uses correct ARIA attributes', () => {
    const { getByRole } = render(
      <Accordion title="Details" expanded={false}>
        <p>Content</p>
      </Accordion>
    );
    const button = getByRole('button', { name: 'Details' });
    expect(button).toHaveAttribute('aria-expanded', 'false');
  });
});

End-to-End Testing with Playwright

Component tests are great, but they don’t test the full picture — routing, dynamic content loading, multi-step flows. That’s where Playwright comes in.

Setting Up Accessibility Audits

Playwright has built-in support for axe-core through the @axe-core/playwright package:

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

test.describe('Homepage accessibility', () => {
  test('should have no a11y violations', async ({ page }) => {
    await page.goto('/');
    const results = await new AxeBuilder({ page }).analyze();

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

Targeting Specific WCAG Levels

You probably want to test against a specific conformance level rather than every rule:

test('meets WCAG 2.2 AA standards', async ({ page }) => {
  await page.goto('/');
  const results = await new AxeBuilder({ page })
    .withTags(['wcag2a', 'wcag2aa', 'wcag22aa'])
    .analyze();

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

Testing Interactive Flows

Here’s where e2e accessibility testing gets really valuable. Testing flows that involve state changes, navigation, and dynamic content:

test('form submission flow is accessible', async ({ page }) => {
  await page.goto('/contact');

  // Audit the initial form state
  let results = await new AxeBuilder({ page })
    .include('#contact-form')
    .analyze();
  expect(results.violations).toEqual([]);

  // Submit with empty fields to trigger validation
  await page.getByRole('button', { name: 'Submit' }).click();

  // Audit the error state
  results = await new AxeBuilder({ page })
    .include('#contact-form')
    .analyze();
  expect(results.violations).toEqual([]);

  // Check that error messages are announced
  const errorMessages = page.getByRole('alert');
  await expect(errorMessages.first()).toBeVisible();
});

Keyboard Navigation Tests

Playwright can simulate keyboard-only navigation, which is perfect for testing tab order and focus management:

test('main navigation is keyboard accessible', async ({ page }) => {
  await page.goto('/');

  // Tab to skip link
  await page.keyboard.press('Tab');
  const skipLink = page.getByText('Skip to main content');
  await expect(skipLink).toBeFocused();

  // Use skip link
  await page.keyboard.press('Enter');
  const main = page.getByRole('main');
  await expect(main).toBeFocused();

  // Tab through navigation items
  await page.goto('/');
  await page.keyboard.press('Tab'); // Skip link
  await page.keyboard.press('Tab'); // First nav item

  const firstNavLink = page.getByRole('navigation').getByRole('link').first();
  await expect(firstNavLink).toBeFocused();
});
test('modal traps focus correctly', async ({ page }) => {
  await page.goto('/dashboard');

  // Open modal
  await page.getByRole('button', { name: 'Settings' }).click();
  const dialog = page.getByRole('dialog');
  await expect(dialog).toBeVisible();

  // Tab through all focusable elements in the modal
  const focusableElements = dialog.locator(
    'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
  );
  const count = await focusableElements.count();

  for (let i = 0; i < count + 1; i++) {
    await page.keyboard.press('Tab');
  }

  // Focus should wrap back to the first element, not escape the modal
  const activeElement = page.locator(':focus');
  await expect(dialog).toContainText(await activeElement.textContent() || '');

  // Escape closes the modal
  await page.keyboard.press('Escape');
  await expect(dialog).not.toBeVisible();
});
tip

Run Playwright a11y tests against your staging environment as part of your deployment pipeline. Testing against a real deployment catches issues that local dev servers might miss; like missing alt text on CMS-managed images.

Setting Up Lighthouse CI

Lighthouse CI gives you performance and accessibility scores on every pull request. It’s a broad audit that complements the targeted testing we’ve set up with axe-core.

GitHub Actions Workflow

Here’s a workflow that runs Lighthouse CI on every PR:

name: Accessibility Audit
on:
  pull_request:
    branches: [main]

jobs:
  a11y-audit:
    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
        run: npm run build

      - name: Run Lighthouse CI
        uses: treosh/lighthouse-ci-action@v12
        with:
          configPath: ./lighthouserc.json
          uploadArtifacts: true
          temporaryPublicStorage: true

Lighthouse CI Configuration

Create a lighthouserc.json at your project root:

{
  "ci": {
    "collect": {
      "staticDistDir": "./dist",
      "url": [
        "http://localhost/",
        "http://localhost/articles/",
        "http://localhost/contact/"
      ]
    },
    "assert": {
      "assertions": {
        "categories:accessibility": ["error", { "minScore": 0.9 }],
        "color-contrast": "error",
        "image-alt": "error",
        "label": "error",
        "link-name": "error",
        "button-name": "error",
        "html-has-lang": "error",
        "meta-viewport": "error"
      }
    },
    "upload": {
      "target": "temporary-public-storage"
    }
  }
}

This configuration fails the build if the accessibility score drops below 90% or if any critical a11y audit fails. Adjust the minScore threshold based on where your site currently stands.

Note: Start realistic and ratchet it up over time.

Integrating axe-core Directly in CI

For more granular control than Lighthouse provides, run axe-core audits directly in your CI pipeline:

name: A11y Tests
on:
  pull_request:
    branches: [main]

jobs:
  accessibility:
    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
        run: npm run build

      - name: Start server
        run: npx serve dist -l 3000 &

      - name: Wait for server
        run: npx wait-on http://localhost:3000

      - name: Run Playwright a11y tests
        run: npx playwright test --project=a11y

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

Playwright Config for A11y Tests

Separate your a11y tests into their own project so they can run independently:

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

export default defineConfig({
  projects: [
    {
      name: 'a11y',
      testDir: './tests/a11y',
      use: {
        baseURL: 'http://localhost:3000',
      },
    },
    // ... other test projects
  ],
});

Handling Violations Gracefully

When you first add automated a11y testing to an existing project, you’ll probably get a wall of violations. That’s normal. Here’s how to deal with it without losing your mind.

Start with Critical and Serious Only

axe-core categorizes violations by impact: critical, serious, moderate, and minor. Start by failing your build only on the top two:

test('no critical or serious a11y violations', async ({ page }) => {
  await page.goto('/');
  const results = await new AxeBuilder({ page }).analyze();

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

  expect(criticalAndSerious).toEqual([]);
});

Once those are clean, expand to moderate, then minor. Trying to fix everything at once leads to burnout and a stalled PR.

Excluding Known Issues Temporarily

If you have known issues that can’t be fixed immediately, exclude specific rules rather than disabling the entire test:

const results = await new AxeBuilder({ page })
  .disableRules(['color-contrast']) // Known issue, tracked in JIRA-1234
  .analyze();
important

Always leave a comment explaining why a rule is disabled and link to the tracking ticket. Disabled rules without context become permanent exceptions.

Creating an Accessibility Scorecard

Track your progress over time by logging violation counts:

// tests/a11y/audit-all-pages.spec.ts
import { test } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
import fs from 'fs';

const pages = ['/', '/articles/', '/about/', '/contact/'];

test('audit all pages and generate report', async ({ page }) => {
  const report: Record<string, number> = {};

  for (const url of pages) {
    await page.goto(url);
    const results = await new AxeBuilder({ page }).analyze();
    report[url] = results.violations.length;
  }

  fs.writeFileSync(
    'a11y-report.json',
    JSON.stringify(report, null, 2)
  );

  // Log for CI visibility
  console.table(report);
});

SPA-Specific Accessibility Concerns

Single-page applications introduce accessibility challenges that static sites don’t have. If you’re building with React, Vue, or any client-side routing framework, watch out for these.

Route Change Announcements

When a user navigates in an SPA, the browser doesn’t do a full page load. Screen readers don’t automatically announce the new content. You need to handle this manually.

// A simple route announcer component
import { useEffect, useRef } from 'react';
import { useLocation } from 'react-router-dom';

function RouteAnnouncer() {
  const location = useLocation();
  const announcerRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    // Set the announcement text to the page title
    if (announcerRef.current) {
      announcerRef.current.textContent =
        document.title || 'Page loaded';
    }
  }, [location.pathname]);

  return (
    <div
      ref={announcerRef}
      role="status"
      aria-live="polite"
      aria-atomic="true"
      style={{
        position: 'absolute',
        width: '1px',
        height: '1px',
        overflow: 'hidden',
        clip: 'rect(0, 0, 0, 0)',
        whiteSpace: 'nowrap',
      }}
    />
  );
}

Focus Management on Navigation

After a route change, focus should move to a logical place—usually the main content area or the page heading:

import { useEffect, useRef } from 'react';
import { useLocation } from 'react-router-dom';

function useFocusOnRouteChange() {
  const location = useLocation();
  const mainRef = useRef<HTMLElement>(null);

  useEffect(() => {
    // Move focus to main content on route change
    if (mainRef.current) {
      mainRef.current.focus();
    }
  }, [location.pathname]);

  return mainRef;
}

// In your layout
function Layout({ children }: { children: React.ReactNode }) {
  const mainRef = useFocusOnRouteChange();

  return (
    <>
      <nav>{/* navigation */}</nav>
      <main ref={mainRef} tabIndex={-1}>
        {children}
      </main>
    </>
  );
}

Testing SPA Accessibility

test('route changes are announced to screen readers', async ({ page }) => {
  await page.goto('/');

  // Navigate to a new page
  await page.getByRole('link', { name: 'Articles' }).click();

  // Check that the route announcer updated
  const announcer = page.locator('[role="status"][aria-live="polite"]');
  await expect(announcer).toHaveText(/articles/i);

  // Check that focus moved appropriately
  const main = page.getByRole('main');
  await expect(main).toBeFocused();
});

test('dynamic content updates are announced', async ({ page }) => {
  await page.goto('/search');

  // Perform a search
  await page.getByRole('searchbox').fill('accessibility');
  await page.getByRole('button', { name: 'Search' }).click();

  // Check that results are announced
  const resultsRegion = page.locator('[aria-live="polite"]');
  await expect(resultsRegion).toContainText(/results/i);
});

Building Your Accessibility Testing Checklist

Here’s a practical checklist you can adapt for your CI pipeline. Not every project needs all of these, but it’s a solid starting point:

In your linter (every commit):

  • All images have alt text
  • Form inputs have associated labels
  • Click handlers on non-interactive elements have keyboard alternatives
  • ARIA attributes are valid

In component tests (every PR):

  • Each component passes axe() with no violations
  • Interactive components work with keyboard events
  • Components in different states (loading, error, empty) are accessible

In e2e tests (every PR or nightly):

  • Critical user flows are keyboard navigable
  • Page-level audits pass WCAG 2.2 AA
  • Focus management works on route changes
  • Error states are announced to screen readers

In manual audits (quarterly or before major releases):

  • Full screen reader walkthrough (VoiceOver, NVDA)
  • Keyboard-only navigation of all flows
  • Zoom to 200% without breaking layout
  • Test with browser high contrast mode

The Bottom Line

Accessibility testing in CI isn’t about catching every single issue automatically—it’s about preventing regressions and building a culture where accessibility is part of the definition of “done.”

Start small. Add jest-axe to one component test. Set up a Lighthouse CI check on your main pages. Write one Playwright test that tabs through your nav. Each of these takes minutes to set up and saves hours of manual auditing down the road.

The tools are mature, the setup is straightforward, and the payoff is real. Your users—all of them—will benefit.

Now go automate your a11y pipeline. The internet will be a little more usable for it.

bonus

Quick Reference:

Continue Reading

Discover more insights and stories that you might be interested in.