Skip to main content
Accessibility Testing·Lesson 5 of 5

Accessibility CI Integration

Accessibility testing in CI/CD catches regressions before they reach production. By automating checks on every pull request, you ensure that accessibility never degrades. This lesson shows you how to build a complete accessibility pipeline.

The Accessibility CI Strategy

Developer pushes code
        
        
┌─────────────────┐
 Linting (ESLint) ──> Catches JSX a11y violations in code
└────────┬────────┘
         
┌─────────────────┐
 Unit Tests       ──> axe-core checks on rendered components
└────────┬────────┘
         
┌─────────────────┐
 Build            ──> Compiles the application
└────────┬────────┘
         
┌─────────────────┐
 E2E A11y Tests   ──> Playwright + axe on running app
└────────┬────────┘
         
┌─────────────────┐
 Lighthouse Audit│ ──> Accessibility score check
└────────┬────────┘
         
┌─────────────────┐
 Quality Gate     ──> Block merge if score < threshold
└─────────────────┘

Step 1: ESLint JSX Accessibility Plugin

The first line of defense catches issues at the code level.

# Install the plugin
pnpm add -D eslint-plugin-jsx-a11y
// eslint.config.js
import jsxA11y from 'eslint-plugin-jsx-a11y';

export default [
  {
    plugins: {
      'jsx-a11y': jsxA11y,
    },
    rules: {
      // Require alt text on images
      'jsx-a11y/alt-text': 'error',

      // Require htmlFor on labels
      'jsx-a11y/label-has-associated-control': 'error',

      // No access key (inconsistent across browsers)
      'jsx-a11y/no-access-key': 'error',

      // Interactive elements must be focusable
      'jsx-a11y/interactive-supports-focus': 'error',

      // Click events need keyboard equivalents
      'jsx-a11y/click-events-have-key-events': 'error',
      'jsx-a11y/no-static-element-interactions': 'error',

      // ARIA rules
      'jsx-a11y/aria-props': 'error',
      'jsx-a11y/aria-role': 'error',
      'jsx-a11y/aria-unsupported-elements': 'error',

      // Heading hierarchy
      'jsx-a11y/heading-has-content': 'error',

      // Anchor links must have content
      'jsx-a11y/anchor-has-content': 'error',
      'jsx-a11y/anchor-is-valid': 'error',
    },
  },
];

Step 2: Component-Level A11y Tests

// tests/a11y/components.spec.ts
import { render } from '@testing-library/react';
import { axe, toHaveNoViolations } from 'jest-axe';

expect.extend(toHaveNoViolations);

// Test a navigation component
describe('Navigation accessibility', () => {
  it('has proper landmark role', async () => {
    const { container } = render(<Nav />);
    const nav = container.querySelector('nav');
    expect(nav).toHaveAttribute('aria-label');

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

// Test a form component
describe('Login form accessibility', () => {
  it('labels all inputs', async () => {
    const { container, getByLabelText } = render(<LoginForm />);

    // Verify labels exist
    expect(getByLabelText('Email')).toBeInTheDocument();
    expect(getByLabelText('Password')).toBeInTheDocument();

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

  it('announces errors to screen readers', async () => {
    const { container, getByRole } = render(<LoginForm />);

    // Submit empty form
    const submitButton = getByRole('button', { name: 'Sign in' });
    submitButton.click();

    // Check that error messages have alert role
    const alerts = container.querySelectorAll('[role="alert"]');
    expect(alerts.length).toBeGreaterThan(0);
  });
});

Step 3: E2E Accessibility Tests with Playwright

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

// Define all pages to test
const pagesToTest = [
  { url: '/', name: 'Homepage' },
  { url: '/courses', name: 'Courses' },
  { url: '/blog', name: 'Blog' },
];

// Generate tests for each page
for (const { url, name } of pagesToTest) {
  test.describe(`${name} accessibility`, () => {
    test('passes WCAG 2.2 AA checks', async ({ page }) => {
      await page.goto(url);

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

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

    test('keyboard navigation works', async ({ page }) => {
      await page.goto(url);

      // Tab through the page and verify focus is visible
      for (let i = 0; i < 10; i++) {
        await page.keyboard.press('Tab');
        const focusedElement = await page.evaluate(() => {
          const el = document.activeElement;
          if (!el || el === document.body) return null;
          const style = window.getComputedStyle(el);
          return {
            tag: el.tagName,
            outline: style.outline,
            boxShadow: style.boxShadow,
            text: el.textContent?.trim().slice(0, 50),
          };
        });

        // Verify something is focused (not just body)
        if (focusedElement) {
          expect(
            focusedElement.outline !== 'none' ||
            focusedElement.boxShadow !== 'none'
          ).toBeTruthy();
        }
      }
    });

    test('skip link works', async ({ page }) => {
      await page.goto(url);

      // Press Tab to focus skip link
      await page.keyboard.press('Tab');

      const skipLink = page.locator('a[href="#main-content"]').first();
      if (await skipLink.isVisible()) {
        await skipLink.click();

        // Verify focus moved to main content
        const focusedId = await page.evaluate(
          () => document.activeElement?.id
        );
        expect(focusedId).toBe('main-content');
      }
    });
  });
}

// Test dynamic interactions
test('course filter maintains accessibility', async ({ page }) => {
  await page.goto('/courses');

  // Apply a filter
  await page.click('text=Web Dev');

  // Wait for results to update
  await page.waitForTimeout(500);

  // Re-scan after interaction
  const results = await new AxeBuilder({ page })
    .include('[data-testid="course-list"]')
    .withTags(['wcag2a', 'wcag2aa'])
    .analyze();

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

  // Verify filter result is announced
  const status = page.locator('[role="status"]');
  await expect(status).toBeVisible();
});

Step 4: Complete CI Pipeline

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

on:
  pull_request:
    branches: [main]

jobs:
  lint:
    name: A11y Lint
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: 'pnpm'
      - run: pnpm install --frozen-lockfile
      - run: pnpm eslint --ext .tsx,.ts app/ --rule 'jsx-a11y/alt-text: error'

  unit-a11y:
    name: Component A11y Tests
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: 'pnpm'
      - run: pnpm install --frozen-lockfile
      - run: pnpm test -- --testPathPattern=a11y

  e2e-a11y:
    name: E2E A11y Tests
    runs-on: ubuntu-latest
    needs: [lint, unit-a11y]
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: 'pnpm'

      - run: pnpm install --frozen-lockfile
      - run: npx playwright install --with-deps chromium

      - name: Build application
        run: pnpm build

      - name: Start server
        run: pnpm start &

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

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

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

  lighthouse:
    name: Lighthouse A11y Score
    runs-on: ubuntu-latest
    needs: [lint, unit-a11y]
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: 'pnpm'

      - run: pnpm install --frozen-lockfile
      - run: pnpm build
      - run: pnpm start &
      - run: npx wait-on http://localhost:3000

      - name: Lighthouse audit
        run: |
          npx lighthouse http://localhost:3000 \
            --only-categories=accessibility \
            --chrome-flags="--headless --no-sandbox" \
            --output=json \
            --output-path=./lighthouse.json

      - name: Check accessibility score
        run: |
          SCORE=$(node -e "const r=require('./lighthouse.json'); console.log(r.categories.accessibility.score)")
          echo "Accessibility score: $SCORE"
          if (( $(echo "$SCORE < 0.9" | bc -l) )); then
            echo "FAIL: Score $SCORE is below 0.9 threshold"
            exit 1
          fi
          echo "PASS: Score meets threshold"

  quality-gate:
    name: A11y Quality Gate
    runs-on: ubuntu-latest
    needs: [e2e-a11y, lighthouse]
    steps:
      - name: All accessibility checks passed
        run: echo "Accessibility quality gate passed. Safe to merge."

Tracking Accessibility Over Time

// scripts/a11y-report.ts
// Generate a trend report from CI results

interface A11yResult {
  date: string;
  page: string;
  violations: number;
  score: number;
}

function generateTrendReport(results: A11yResult[]) {
  const byPage = new Map<string, A11yResult[]>();

  for (const result of results) {
    const existing = byPage.get(result.page) || [];
    existing.push(result);
    byPage.set(result.page, existing);
  }

  console.log('Accessibility Trend Report');
  console.log('='.repeat(60));

  for (const [page, pageResults] of byPage) {
    const latest = pageResults[pageResults.length - 1];
    const previous = pageResults[pageResults.length - 2];

    const trend = previous
      ? latest.violations < previous.violations
        ? 'IMPROVING'
        : latest.violations > previous.violations
        ? 'DEGRADING'
        : 'STABLE'
      : 'NEW';

    console.log(`\n${page}`);
    console.log(`  Violations: ${latest.violations} (${trend})`);
    console.log(`  Score: ${(latest.score * 100).toFixed(0)}%`);
  }
}

Key Takeaways

  • ESLint catches a11y issues at code time, before tests even run
  • axe-core in unit tests verifies individual component accessibility
  • Playwright + axe-core tests the running application end-to-end
  • Lighthouse provides an accessibility score for quality gates
  • A quality gate in CI prevents accessibility regressions from merging
  • Track accessibility scores over time to measure improvement
  • Automated CI checks are the foundation, but always supplement with manual screen reader testing

You have completed the Accessibility Testing course. You now have the skills to build accessible applications and verify they work for all users through both manual and automated testing.