axe-core Testing Guide: Automated Accessibility Testing in Practice

axe-core Testing Guide: Automated Accessibility Testing in Practice

axe-core is the accessibility testing engine behind Deque's browser extensions, most CI accessibility tools, and the built-in accessibility checks in browser DevTools. Understanding how to use it directly gives you more control over what you test, what you suppress, and how violations surface in your build pipeline.

This guide covers axe-core from initial setup through production CI integration.

What axe-core Actually Tests

axe-core runs a set of rules against the DOM. Each rule tests for a specific accessibility requirement — typically mapped to WCAG 2.x success criteria or ARIA specification requirements.

Rules are organized into:

  • Violations: Must be fixed. Definite accessibility failures.
  • Passes: Rules that passed for this context.
  • Incomplete (needs review): axe found something that requires human judgment. It can't determine pass or fail automatically.
  • Inapplicable: Rules that don't apply to the current page content.

Most developers only look at violations. But incomplete results are important — they're the cases axe found suspicious but couldn't definitively classify. Missing alt attributes with non-empty text, ARIA labels that might not be meaningful, color contrast on images.

Installation Options

axe-core works in several contexts:

# Core library
npm install --save-dev axe-core

<span class="hljs-comment"># Playwright integration
npm install --save-dev @axe-core/playwright

<span class="hljs-comment"># Cypress integration
npm install --save-dev cypress-axe

<span class="hljs-comment"># Jest with testing-library
npm install --save-dev jest-axe @testing-library/react @testing-library/jest-dom

Jest Integration (Component Testing)

For React, Vue, or other component-based testing, jest-axe wraps axe-core in a format compatible with Jest matchers:

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

expect.extend(toHaveNoViolations);

describe('Button', () => {
  it('is accessible', async () => {
    const { container } = render(
      <Button onClick={() => {}}>Save changes</Button>
    );

    const results = await axe(container);
    expect(results).toHaveNoViolations();
  });

  it('is accessible when disabled', async () => {
    const { container } = render(
      <Button onClick={() => {}} disabled>
        Save changes
      </Button>
    );

    const results = await axe(container);
    expect(results).toHaveNoViolations();
  });

  it('is accessible with an icon-only variant', async () => {
    const { container } = render(
      <Button onClick={() => {}} aria-label="Delete item">
        <TrashIcon />
      </Button>
    );

    const results = await axe(container);
    expect(results).toHaveNoViolations();
  });
});

Testing Form Components

Forms have the densest concentration of accessibility failures. Test them explicitly:

// src/components/LoginForm.test.jsx
import { render, screen } from '@testing-library/react';
import { axe, toHaveNoViolations } from 'jest-axe';
import { LoginForm } from './LoginForm';

expect.extend(toHaveNoViolations);

describe('LoginForm', () => {
  it('is accessible in default state', async () => {
    const { container } = render(<LoginForm onSubmit={jest.fn()} />);
    const results = await axe(container);
    expect(results).toHaveNoViolations();
  });

  it('is accessible with validation errors', async () => {
    const { container } = render(
      <LoginForm
        onSubmit={jest.fn()}
        errors={{
          email: 'Please enter a valid email',
          password: 'Password must be at least 8 characters',
        }}
      />
    );

    const results = await axe(container);
    expect(results).toHaveNoViolations();
  });

  it('is accessible with loading state', async () => {
    const { container } = render(
      <LoginForm onSubmit={jest.fn()} isLoading />
    );

    const results = await axe(container);
    expect(results).toHaveNoViolations();
  });
});

Playwright Integration (Page-Level Testing)

@axe-core/playwright runs axe against full rendered pages in a real browser. This catches violations that only appear when CSS is applied, when JavaScript has executed, and when real browser rendering is in play.

// tests/a11y/pages.test.js
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';

const PAGES_TO_TEST = [
  { name: 'Home', path: '/' },
  { name: 'Login', path: '/login' },
  { name: 'Dashboard', path: '/dashboard' },
  { name: 'Settings', path: '/settings' },
  { name: 'Profile', path: '/profile' },
];

for (const { name, path } of PAGES_TO_TEST) {
  test(`${name} page has no accessibility violations`, async ({ page }) => {
    await page.goto(path);
    // Wait for dynamic content to stabilize
    await page.waitForLoadState('networkidle');

    const results = await new AxeBuilder({ page })
      .withTags(['wcag2a', 'wcag2aa', 'wcag21aa'])
      .analyze();

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

Testing Authenticated Pages

// tests/a11y/authenticated-pages.test.js
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';

// Use stored auth state from a separate setup step
test.use({ storageState: 'playwright/.auth/user.json' });

test('dashboard is accessible when logged in', async ({ page }) => {
  await page.goto('/dashboard');
  await page.waitForSelector('[data-testid="dashboard-content"]');

  const results = await new AxeBuilder({ page })
    .withTags(['wcag2a', 'wcag2aa'])
    .analyze();

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

Scoping Axe to Specific Regions

When a page has a third-party widget you can't fix, scope axe to the parts you own:

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

  const results = await new AxeBuilder({ page })
    .include('main') // only test the main content area
    .withTags(['wcag2a', 'wcag2aa'])
    .analyze();

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

Or exclude specific elements:

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

Handling the Incomplete Results

Incomplete results are a hint that something might be wrong but axe isn't sure. They require manual verification. Don't ignore them indefinitely — schedule time to review each one.

test('no violations and no unresolved incomplete items', async ({ page }) => {
  await page.goto('/');
  const results = await new AxeBuilder({ page }).analyze();

  // Fail on violations
  expect(results.violations).toEqual([]);

  // Log incomplete items for manual review (don't fail the build, but track them)
  if (results.incomplete.length > 0) {
    console.warn('Items requiring manual review:');
    results.incomplete.forEach(item => {
      console.warn(`- ${item.id}: ${item.description}`);
      item.nodes.forEach(node => console.warn(`  Node: ${node.html}`));
    });
  }
});

Rule Configuration

You can disable specific rules if they produce false positives in your codebase:

const results = await new AxeBuilder({ page })
  .withTags(['wcag2a', 'wcag2aa'])
  .disableRules(['color-contrast']) // if you're relying on server-rendered colors that axe can't see
  .analyze();

Or enable rules that aren't in the standard tags:

const results = await new AxeBuilder({ page })
  .withTags(['wcag2a', 'wcag2aa'])
  .options({
    rules: {
      'landmark-one-main': { enabled: true },
      'region': { enabled: true },
    },
  })
  .analyze();

See the axe-core rule descriptions for the full rule catalog.

Surfacing Violations Clearly in CI

Default violation output from axe is dense. Format it for readability:

function formatViolations(violations) {
  if (violations.length === 0) return '';

  return violations
    .map(v => {
      const nodes = v.nodes
        .map(n => `    - ${n.html}\n      Fix: ${n.failureSummary}`)
        .join('\n');
      return `[${v.impact.toUpperCase()}] ${v.id}\n  ${v.description}\n  Affected elements:\n${nodes}`;
    })
    .join('\n\n');
}

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

  if (results.violations.length > 0) {
    throw new Error(`\nAccessibility violations found:\n\n${formatViolations(results.violations)}`);
  }
});

Snapshot Testing for Violation Counts

If you have existing violations you can't immediately fix, snapshot the count rather than ignoring violations entirely:

test('violation count does not increase', async ({ page }) => {
  await page.goto('/');
  const results = await new AxeBuilder({ page }).analyze();

  // This will fail if you introduce NEW violations
  // Update the snapshot number as you fix violations
  expect(results.violations.length).toBeLessThanOrEqual(3);

  // Better: snapshot the specific violation IDs so you know what's known
  const violationIds = results.violations.map(v => v.id).sort();
  expect(violationIds).toEqual(['color-contrast', 'label', 'link-name']);
});

CI Integration

Add accessibility tests to your CI pipeline as a separate job that runs alongside your existing tests:

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

on:
  pull_request:
  push:
    branches: [main]

jobs:
  a11y:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20

      - name: Install dependencies
        run: npm ci

      - name: Install Playwright browsers
        run: npx playwright install --with-deps chromium

      - name: Build application
        run: npm run build

      - name: Start application
        run: npm start &
        env:
          NODE_ENV: test

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

      - name: Run accessibility tests
        run: npx playwright test tests/a11y/

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

What axe-core Cannot Test

Keep these limitations in mind:

  1. Reading order: axe can't tell if the DOM order matches visual order in a logical way
  2. Focus management: tabbing through the page requires real keyboard simulation, not static analysis
  3. Alt text quality: presence is checked, meaningfulness is not
  4. Error message clarity: axe can tell if errors are programmatically associated with inputs, not if the error message is useful
  5. Cognitive complexity: no automated tool can evaluate whether content is understandable
  6. Screen reader behavior: DOM analysis doesn't predict how specific screen readers will announce content

Treat axe as your first line of defense, not your complete accessibility strategy.

Read more