Skip to main content
Accessibility Testing·Lesson 4 of 5

Automated Accessibility Tools

Automated tools catch approximately 30-50% of accessibility issues. They excel at finding programmatic violations like missing alt text, insufficient contrast, and improper ARIA usage. Manual testing covers the rest, but automation provides a fast, consistent baseline.

What Automation Can and Cannot Catch

Can DetectCannot Detect
Missing alt textWhether alt text is meaningful
Insufficient color contrastWhether content is understandable
Missing form labelsWhether labels are descriptive
Duplicate IDsLogical reading order
Missing document languageKeyboard usability
Invalid ARIA attributesWhether ARIA is used correctly
Missing skip navigationWhether focus management works

axe-core: The Industry Standard

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

Browser Extension

Install the axe DevTools extension:
1. Chrome Web Store > search "axe DevTools"
2. Install the extension
3. Open DevTools (F12) > axe DevTools tab
4. Click "Scan All of My Page"

The scan results show:
- Violations (must fix)
- Needs Review (manual check required)
- Passes (confirmed accessible)
- Incomplete (could not determine)

axe-core in Unit Tests

# Install axe-core for React testing
pnpm add -D @axe-core/react jest-axe
// __tests__/components/course-card.test.tsx
import { render } from '@testing-library/react';
import { axe, toHaveNoViolations } from 'jest-axe';
import { CourseCard } from '@/app/components/course-cards';

expect.extend(toHaveNoViolations);

describe('CourseCard accessibility', () => {
  it('should have no accessibility violations', async () => {
    const { container } = render(
      <CourseCard
        title="Accessibility Testing"
        summary="Learn to test for accessibility"
        level="intermediate"
        slug="accessibility-testing"
        tags="a11y, testing"
        lessonCount={5}
      />
    );

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

  it('should have proper heading hierarchy', async () => {
    const { container } = render(
      <main>
        <h1>Courses</h1>
        <CourseCard
          title="Accessibility Testing"
          summary="Learn to test for accessibility"
          level="intermediate"
          slug="accessibility-testing"
          tags="a11y, testing"
          lessonCount={5}
        />
      </main>
    );

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

axe-core in Playwright

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

test.describe('Course page accessibility', () => {
  test('should pass axe accessibility checks', async ({ page }) => {
    await page.goto('/courses/accessibility-testing');

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

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

  test('should pass axe checks on mobile viewport', async ({ page }) => {
    await page.setViewportSize({ width: 375, height: 812 });
    await page.goto('/courses/accessibility-testing');

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

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

  test('should pass axe checks after interaction', async ({ page }) => {
    await page.goto('/courses');

    // Open a filter dropdown
    await page.click('[data-testid="level-filter"]');

    // Scan after the DOM has changed
    const results = await new AxeBuilder({ page })
      .include('[data-testid="course-list"]')
      .analyze();

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

Handling Violations in Tests

// tests/helpers/a11y-reporter.ts
import { AxeResults, Result } from 'axe-core';

export function formatViolations(violations: Result[]): string {
  if (violations.length === 0) return 'No violations found';

  return violations
    .map((violation) => {
      const nodes = violation.nodes
        .map((node) => {
          return `  - ${node.html}\n    Fix: ${node.failureSummary}`;
        })
        .join('\n');

      return [
        `Rule: ${violation.id}`,
        `Impact: ${violation.impact}`,
        `Description: ${violation.description}`,
        `Help: ${violation.helpUrl}`,
        `Elements:\n${nodes}`,
      ].join('\n');
    })
    .join('\n\n---\n\n');
}

// Usage in tests
test('accessible homepage', async ({ page }) => {
  await page.goto('/');
  const results = await new AxeBuilder({ page }).analyze();

  if (results.violations.length > 0) {
    console.log(formatViolations(results.violations));
  }

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

Lighthouse Accessibility Audit

Lighthouse includes an accessibility audit powered by axe-core.

# Run Lighthouse from the command line
npx lighthouse https://localhost:3000 \
  --only-categories=accessibility \
  --output=html \
  --output-path=./lighthouse-a11y.html

# Run in CI with Chrome headless
npx lighthouse https://staging.example.com \
  --only-categories=accessibility \
  --chrome-flags="--headless --no-sandbox" \
  --output=json \
  --output-path=./lighthouse-results.json

Lighthouse in CI/CD

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

on:
  pull_request:
    branches: [main]

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

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: 22

      - name: Install and build
        run: |
          pnpm install --frozen-lockfile
          pnpm build

      - name: Start server
        run: pnpm start &

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

      - name: Run Lighthouse
        uses: treosh/lighthouse-ci-action@v12
        with:
          urls: |
            http://localhost:3000/
            http://localhost:3000/courses
            http://localhost:3000/blog
          budgetPath: ./lighthouse-budget.json

      - name: Check accessibility score
        run: |
          SCORE=$(jq '.categories.accessibility.score' lighthouse-results.json)
          if (( $(echo "$SCORE < 0.9" | bc -l) )); then
            echo "Accessibility score $SCORE is below 90%"
            exit 1
          fi

Pa11y: CLI Accessibility Testing

Pa11y provides a simple command-line interface for accessibility testing.

# Install pa11y
pnpm add -D pa11y pa11y-ci

# Scan a single page
npx pa11y https://localhost:3000/courses

# Scan with specific standard
npx pa11y --standard WCAG2AA https://localhost:3000

# Scan multiple pages with pa11y-ci
npx pa11y-ci

Pa11y Configuration

// .pa11yci.json
{
  "defaults": {
    "standard": "WCAG2AA",
    "timeout": 10000,
    "wait": 1000,
    "chromeLaunchConfig": {
      "args": ["--no-sandbox"]
    }
  },
  "urls": [
    "http://localhost:3000/",
    "http://localhost:3000/courses",
    "http://localhost:3000/blog",
    {
      "url": "http://localhost:3000/courses/accessibility-testing",
      "actions": [
        "wait for element #lesson-list to be visible"
      ]
    }
  ]
}

Color Contrast Testing

// Programmatic contrast ratio calculation
function getLuminance(r, g, b) {
  const [rs, gs, bs] = [r, g, b].map((c) => {
    c = c / 255;
    return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
  });
  return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs;
}

function getContrastRatio(rgb1, rgb2) {
  const l1 = getLuminance(...rgb1);
  const l2 = getLuminance(...rgb2);
  const lighter = Math.max(l1, l2);
  const darker = Math.min(l1, l2);
  return (lighter + 0.05) / (darker + 0.05);
}

// Test brand colors
const brandRed = [226, 27, 27];    // #E21B1B
const white = [255, 255, 255];
const black = [0, 0, 0];

console.log(`Red on white: ${getContrastRatio(brandRed, white).toFixed(2)}:1`);
// Red on white: 4.63:1 -- passes AA for large text only
console.log(`Red on black: ${getContrastRatio(brandRed, black).toFixed(2)}:1`);
// Red on black: 4.53:1 -- passes AA for large text only

Building an Accessibility Test Suite

// tests/a11y/full-suite.spec.ts
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';

const pages = [
  { url: '/', name: 'Homepage' },
  { url: '/courses', name: 'Courses listing' },
  { url: '/blog', name: 'Blog listing' },
  { url: '/courses/accessibility-testing', name: 'Course detail' },
  { url: '/blog/first-post', name: 'Blog post' },
];

for (const { url, name } of pages) {
  test(`${name} (${url}) passes accessibility checks`, async ({ page }) => {
    await page.goto(url);

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

    expect(
      results.violations,
      `Found ${results.violations.length} violations on ${name}`
    ).toEqual([]);
  });
}

Key Takeaways

  • Automated tools catch 30-50% of accessibility issues -- use them as a baseline
  • axe-core is the industry standard engine, available in browser extensions, unit tests, and E2E tests
  • Integrate accessibility checks into CI/CD so regressions are caught early
  • Color contrast testing can be fully automated
  • Always combine automated testing with manual screen reader testing

In the final lesson, you will learn how to integrate accessibility testing into your CI/CD pipeline for continuous compliance.