Front-end Engineering Lab
PatternsTesting Strategies

A11y Testing Automation

Automated accessibility testing with axe-core and Pa11y

Accessibility Testing Automation

Accessibility testing ensures your application is usable by everyone, including people with disabilities. Automated tools catch 30-40% of accessibility issues—manual testing is still essential, but automation provides a solid foundation.

Why Automate A11y Testing?

What automated tools catch:

  • Missing alt text on images
  • Insufficient color contrast
  • Missing form labels
  • Invalid ARIA attributes
  • Keyboard navigation issues
  • Missing page titles

What they don't catch:

  • Logical tab order
  • Focus management
  • Screen reader UX
  • Cognitive load issues
  • Real user experience

axe-core (The Industry Standard)

Setup with jest-axe

npm install --save-dev jest-axe
// jest.setup.ts
import { toHaveNoViolations } from 'jest-axe';

expect.extend(toHaveNoViolations);

Basic Usage

// Component.test.tsx
import { render } from '@testing-library/react';
import { axe } from 'jest-axe';

test('has no accessibility violations', async () => {
  const { container } = render(<MyComponent />);
  
  const results = await axe(container);
  
  expect(results).toHaveNoViolations();
});

Custom Rules

test('checks specific rules', async () => {
  const { container } = render(<Form />);
  
  const results = await axe(container, {
    rules: {
      // Only check these rules
      'color-contrast': { enabled: true },
      'label': { enabled: true },
      'button-name': { enabled: true },
    },
  });
  
  expect(results).toHaveNoViolations();
});

Exclude Regions

test('ignores third-party widgets', async () => {
  const { container } = render(<PageWithAds />);
  
  const results = await axe(container, {
    // Exclude ad container from checks
    exclude: [['.advertisement']],
  });
  
  expect(results).toHaveNoViolations();
});

Playwright Integration

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

test.describe('Homepage Accessibility', () => {
  test('should not have any automatically detectable accessibility issues', async ({ page }) => {
    await page.goto('http://localhost:3000');
    
    const accessibilityScanResults = await new AxeBuilder({ page })
      .analyze();
    
    expect(accessibilityScanResults.violations).toEqual([]);
  });

  test('checks specific WCAG standards', async ({ page }) => {
    await page.goto('http://localhost:3000');
    
    const accessibilityScanResults = await new AxeBuilder({ page })
      .withTags(['wcag2a', 'wcag2aa', 'wcag21aa'])
      .analyze();
    
    expect(accessibilityScanResults.violations).toEqual([]);
  });

  test('excludes third-party content', async ({ page }) => {
    await page.goto('http://localhost:3000');
    
    const accessibilityScanResults = await new AxeBuilder({ page })
      .exclude('.third-party-widget')
      .analyze();
    
    expect(accessibilityScanResults.violations).toEqual([]);
  });
});

Test Multiple Pages

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

for (const path of pages) {
  test(`${path} has no a11y violations`, async ({ page }) => {
    await page.goto(`http://localhost:3000${path}`);
    
    const results = await new AxeBuilder({ page }).analyze();
    
    expect(results.violations).toEqual([]);
  });
}

Pa11y (Command Line Tool)

Installation

npm install --save-dev pa11y pa11y-ci

Configuration

// .pa11yci.json
{
  "defaults": {
    "standard": "WCAG2AA",
    "timeout": 30000,
    "wait": 1000,
    "chromeLaunchConfig": {
      "args": ["--no-sandbox"]
    },
    "ignore": [
      "notice",
      "warning"
    ]
  },
  "urls": [
    "http://localhost:3000/",
    "http://localhost:3000/about",
    "http://localhost:3000/products",
    {
      "url": "http://localhost:3000/dashboard",
      "actions": [
        "set field #email to test@example.com",
        "set field #password to password123",
        "click element button[type=submit]",
        "wait for url to be http://localhost:3000/dashboard"
      ]
    }
  ]
}

Package.json Scripts

{
  "scripts": {
    "test:a11y": "pa11y-ci"
  }
}

Custom Reporter

// scripts/pa11y-report.js
const pa11y = require('pa11y');

async function runPa11y() {
  const results = await pa11y('http://localhost:3000', {
    standard: 'WCAG2AA',
    reporters: ['cli', 'json'],
  });

  const issues = results.issues;
  
  // Group by type
  const errors = issues.filter(i => i.type === 'error');
  const warnings = issues.filter(i => i.type === 'warning');

  console.log(`Found ${errors.length} errors, ${warnings.length} warnings`);

  if (errors.length > 0) {
    console.error('\n❌ Accessibility Errors:');
    errors.forEach(err => {
      console.error(`  - ${err.message}`);
      console.error(`    ${err.selector}`);
      console.error(`    ${err.context}\n`);
    });
    
    process.exit(1);
  }
}

runPa11y();

Lighthouse Accessibility

// Already covered in Performance Monitoring section
// Lighthouse includes accessibility audit

Manual Testing Checklist

Automated tools are a starting point. Always test manually:

Keyboard Navigation

  • All interactive elements are keyboard accessible
  • Tab order is logical
  • Focus indicators are visible
  • No keyboard traps
  • Can close modals with Escape
// Test keyboard navigation
test('modal can be closed with Escape', async () => {
  const user = userEvent.setup();
  render(<Modal />);
  
  const closeButton = screen.getByRole('button', { name: /close/i });
  
  // Open modal (if needed)
  // Focus an element inside
  closeButton.focus();
  
  // Press Escape
  await user.keyboard('{Escape}');
  
  // Modal should be closed
  expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});

Screen Reader Testing

  • Test with NVDA (Windows), JAWS (Windows), VoiceOver (macOS/iOS)
  • All content is announced properly
  • ARIA labels are meaningful
  • Live regions work correctly
  • Form errors are announced
// Verify screen reader text
test('announces error message', () => {
  render(<Form />);
  
  const emailInput = screen.getByRole('textbox', { name: /email/i });
  
  fireEvent.blur(emailInput);
  
  // Error should be associated with input
  expect(emailInput).toHaveAccessibleDescription('Please enter a valid email');
});

Color & Contrast

  • Sufficient color contrast (4.5:1 for text, 3:1 for large text)
  • Information not conveyed by color alone
  • Focus indicators visible
// Check color contrast programmatically
test('has sufficient color contrast', async () => {
  const { container } = render(<Button>Click me</Button>);
  
  const results = await axe(container, {
    rules: {
      'color-contrast': { enabled: true },
    },
  });
  
  expect(results.violations).toHaveLength(0);
});

Forms

  • All inputs have labels
  • Error messages are clear
  • Required fields are indicated
  • Autocomplete attributes for common fields
test('form inputs have proper labels', () => {
  render(<SignupForm />);
  
  // Inputs can be found by their label
  expect(screen.getByLabelText('Email')).toBeInTheDocument();
  expect(screen.getByLabelText('Password')).toBeInTheDocument();
  expect(screen.getByLabelText('Confirm Password')).toBeInTheDocument();
});

CI/CD Integration

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

on: [push, pull_request]

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

Best Practices

  1. Run on Every Page: Include accessibility tests for all pages
  2. Test Interactive States: Hover, focus, active, disabled
  3. Test With Real Users: Automated tools catch 30-40%, humans catch the rest
  4. Use Semantic HTML: Start with accessible foundations
  5. Test Keyboard Navigation: Ensure all functionality works without mouse
  6. Include in CI: Block PRs with accessibility issues
  7. Track Over Time: Monitor accessibility scores

WCAG Compliance Levels

LevelRequirementsUse Case
ABasic accessibilityMinimum legal compliance
AAAddresses major barriersIndustry standard
AAAHighest accessibilityGovernment, critical services

Recommendation: Target WCAG 2.1 Level AA as baseline.

Common Issues & Fixes

Missing Alt Text

// ❌ BAD
<img src="/photo.jpg" />

// ✅ GOOD
<img src="/photo.jpg" alt="Team celebrating product launch" />

// ✅ GOOD: Decorative images
<img src="/decoration.jpg" alt="" role="presentation" />

Missing Form Labels

// ❌ BAD
<input type="email" placeholder="Email" />

// ✅ GOOD
<label htmlFor="email">Email</label>
<input id="email" type="email" />

// ✅ GOOD: Visually hidden label
<label htmlFor="search" className="sr-only">Search</label>
<input id="search" type="search" placeholder="Search..." />

Insufficient Color Contrast

/* ❌ BAD: 2.5:1 contrast */
.text {
  color: #777;
  background: #fff;
}

/* ✅ GOOD: 7:1 contrast */
.text {
  color: #333;
  background: #fff;
}

Missing ARIA Labels

// ❌ BAD
<button>
  <CloseIcon />
</button>

// ✅ GOOD
<button aria-label="Close dialog">
  <CloseIcon />
</button>

Tools Summary

ToolScopeIntegrationCost
axe-coreComponent/PageJest, PlaywrightFree
Pa11yPage/SiteCLI, CIFree
LighthousePageCLI, CIFree
WavePageBrowser extFree
NVDAScreen readerManualFree
JAWSScreen readerManualPaid

When to Test

Every component: Unit tests with jest-axe
Every page: E2E tests with Playwright
Every deploy: Pa11y in CI
Every sprint: Manual testing with screen readers

Accessibility is not optional—it's a legal requirement in many jurisdictions and the right thing to do.

On this page