Accessibility Testing: Automated and Manual Testing Guide (2026)

Accessibility Testing: Automated and Manual Testing Guide (2026)

Accessibility testing is how you verify your web application works for people using screen readers, keyboard navigation, high-contrast modes, and other assistive technologies. It is also how you avoid lawsuits.

This guide covers the full stack: automated axe-core checks, Playwright accessibility audits, jest-axe for unit tests, keyboard navigation testing, screen reader basics, and how to wire it all into CI so regressions get caught before they ship.

Why Accessibility Testing Is Not Optional

The legal exposure is real. In the US, the ADA (Americans with Disabilities Act) applies to websites and web apps. Title III lawsuits targeting digital properties have been filed against companies of all sizes — grocery chains, banks, streaming services, SaaS tools. The EU Web Accessibility Directive and European Accessibility Act impose similar requirements. In many jurisdictions, failing WCAG 2.1 AA is enough to establish a prima facie case.

WCAG — the Web Content Accessibility Guidelines published by W3C — is the technical standard courts and regulators reference. WCAG 2.1 AA is the most common compliance target. WCAG 2.2 adds a handful of new criteria; WCAG 3.0 is in draft. For most teams, WCAG 2.1 AA is the line to hit.

Beyond legal risk, accessibility directly affects revenue. Approximately 15% of the global population lives with some form of disability. Screen reader users, keyboard-only users, people with low vision or color blindness — these are real users with real purchasing power. An inaccessible checkout flow is a broken checkout flow.

Automated vs Manual Accessibility Testing

Automated tools catch roughly 30-40% of WCAG failures. They are fast, cheap, and run in CI. They cannot catch everything — whether a label is meaningful, whether a flow makes sense to a screen reader user, whether keyboard focus order is logical — those require human judgment.

The correct approach is both:

  1. Automated checks on every build — axe-core, Playwright audits, jest-axe. These catch the obvious failures: missing alt text, insufficient color contrast, missing form labels, incorrect ARIA roles.
  2. Manual testing on key flows — keyboard navigation walk-through, screen reader smoke test, zoom and reflow check. Do this on every significant UI change.

Automated testing without manual testing gives you false confidence. Manual testing without automated testing means regressions slip through. Neither alone is sufficient.

axe-core and axe-playwright

axe-core is the industry-standard accessibility engine. It powers the axe browser extension, Lighthouse accessibility audits, and dozens of testing integrations. The Playwright integration makes it straightforward to run axe against live pages in your test suite.

Install the dependencies:

npm install --save-dev @axe-core/playwright

Basic Playwright test that audits a page:

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

test('homepage has no critical accessibility violations', async ({ page }) => {
  await page.goto('/');

  const accessibilityScanResults = await new AxeBuilder({ page }).analyze();

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

That will fail on the first real violation. If you want to start with a softer landing — audit without failing, log violations to review — swap the expect for logging:

test('homepage accessibility audit', async ({ page }) => {
  await page.goto('/');

  const results = await new AxeBuilder({ page }).analyze();

  if (results.violations.length > 0) {
    console.table(results.violations.map(v => ({
      id: v.id,
      impact: v.impact,
      description: v.description,
      nodes: v.nodes.length,
    })));
  }

  // Fail only on critical and serious violations
  const blocking = results.violations.filter(v =>
    ['critical', 'serious'].includes(v.impact)
  );
  expect(blocking).toEqual([]);
});

Scope your audit to a specific component when you don't want the entire page:

test('modal dialog is accessible', async ({ page }) => {
  await page.goto('/settings');
  await page.click('[data-testid="open-modal"]');
  await page.waitForSelector('[role="dialog"]');

  const results = await new AxeBuilder({ page })
    .include('[role="dialog"]')
    .analyze();

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

Exclude known third-party widgets you cannot fix:

const results = await new AxeBuilder({ page })
  .exclude('#legacy-chat-widget')
  .analyze();

Disable specific rules you've decided to accept (document the reason in code):

const results = await new AxeBuilder({ page })
  // Third-party video player fails color-contrast; vendor issue, tracked in #4521
  .disableRules(['color-contrast'])
  .analyze();

Testing Keyboard Navigation

Keyboard navigation testing cannot be fully automated. You need a human to walk through the application using only Tab, Shift+Tab, Enter, Space, Escape, and arrow keys. But you can write Playwright tests that check the structural requirements.

Test that interactive elements are reachable and operable:

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

  // Tab to first nav item
  await page.keyboard.press('Tab');
  const firstFocusedElement = await page.evaluate(() =>
    document.activeElement?.getAttribute('data-testid')
  );
  expect(firstFocusedElement).toBe('nav-home');

  // Tab through all nav items
  await page.keyboard.press('Tab');
  await page.keyboard.press('Tab');

  // Activate with Enter
  await page.keyboard.press('Enter');
  await expect(page).toHaveURL('/about');
});

test('modal focus trap works correctly', async ({ page }) => {
  await page.goto('/');
  await page.click('[data-testid="open-modal"]');

  const dialog = page.locator('[role="dialog"]');
  await expect(dialog).toBeVisible();

  // Focus should be inside modal
  const focusedInsideModal = await page.evaluate(() => {
    const modal = document.querySelector('[role="dialog"]');
    return modal?.contains(document.activeElement);
  });
  expect(focusedInsideModal).toBe(true);

  // Escape should close modal and return focus
  await page.keyboard.press('Escape');
  await expect(dialog).not.toBeVisible();
});

Check for visible focus indicators — one of the most commonly failed WCAG criteria:

test('focused elements have visible focus ring', async ({ page }) => {
  await page.goto('/');
  await page.keyboard.press('Tab');

  const focusedOutline = await page.evaluate(() => {
    const el = document.activeElement;
    const styles = window.getComputedStyle(el);
    return {
      outline: styles.outline,
      outlineWidth: styles.outlineWidth,
      boxShadow: styles.boxShadow,
    };
  });

  // Outline must be non-zero or box-shadow must provide visible indicator
  const hasVisibleFocus =
    focusedOutline.outlineWidth !== '0px' ||
    focusedOutline.boxShadow !== 'none';

  expect(hasVisibleFocus).toBe(true);
});

For manual keyboard testing, walk through every user-facing flow: login, form submission, navigation, modal open/close, dropdown menus, date pickers, multi-step wizards. Document focus order issues as bugs immediately — they are rarely obvious from code review.

Screen Reader Testing Basics

Screen reader testing requires a real screen reader. The three you need to cover:

  • NVDA + Firefox (Windows, free) — most common among screen reader users
  • JAWS + Chrome (Windows, paid) — enterprise standard
  • VoiceOver + Safari (macOS/iOS, built-in) — covers Apple platforms

You cannot fully automate screen reader testing. What you can do is audit the structural semantics that screen readers depend on:

test('images have meaningful alt text', async ({ page }) => {
  await page.goto('/');

  const images = page.locator('img');
  const count = await images.count();

  for (let i = 0; i < count; i++) {
    const img = images.nth(i);
    const alt = await img.getAttribute('alt');
    const role = await img.getAttribute('role');

    // Decorative images should have role="presentation" or alt=""
    // Meaningful images must have descriptive alt text
    if (role !== 'presentation') {
      expect(alt, `Image at index ${i} missing alt text`).not.toBeNull();
      expect(alt?.trim().length, `Image at index ${i} has empty alt text`).toBeGreaterThan(0);
    }
  }
});

test('form inputs have associated labels', async ({ page }) => {
  await page.goto('/signup');

  const inputs = page.locator('input:not([type="hidden"]):not([type="submit"])');
  const count = await inputs.count();

  for (let i = 0; i < count; i++) {
    const input = inputs.nth(i);
    const id = await input.getAttribute('id');
    const ariaLabel = await input.getAttribute('aria-label');
    const ariaLabelledby = await input.getAttribute('aria-labelledby');

    const hasLabel = id
      ? await page.locator(`label[for="${id}"]`).count() > 0
      : false;

    expect(
      hasLabel || ariaLabel || ariaLabelledby,
      `Input at index ${i} has no accessible label`
    ).toBeTruthy();
  }
});

For manual screen reader testing, use browse mode to navigate by headings (H key in NVDA/JAWS). Every page needs a logical heading structure: one H1, then H2s for major sections, H3s for subsections. Screen reader users use headings to navigate the way sighted users skim visually.

Test your landmark regions: <main>, <nav>, <header>, <footer>, <aside>. These are the waypoints screen reader users jump between. Missing landmarks force linear navigation through every element on the page.

Color Contrast and Visual Accessibility

WCAG 2.1 AA requires a contrast ratio of at least 4.5:1 for normal text and 3:1 for large text (18pt+ or 14pt+ bold). UI components and graphical objects require 3:1 against adjacent colors.

axe-core checks contrast ratios automatically. Run it on every page to catch violations:

test('text meets color contrast requirements', async ({ page }) => {
  await page.goto('/');

  const results = await new AxeBuilder({ page })
    .withRules(['color-contrast'])
    .analyze();

  const contrastViolations = results.violations.filter(
    v => v.id === 'color-contrast'
  );

  if (contrastViolations.length > 0) {
    const details = contrastViolations[0].nodes.map(n => ({
      html: n.html,
      failureSummary: n.failureSummary,
    }));
    console.log('Contrast violations:', JSON.stringify(details, null, 2));
  }

  expect(contrastViolations).toHaveLength(0);
});

Beyond contrast, test that your application works when users override colors. Windows High Contrast Mode and forced color schemes in browsers strip your CSS and apply system colors. Test by enabling forced colors in Playwright:

test('works in forced colors mode', async ({ browser }) => {
  const context = await browser.newContext({
    forcedColors: 'active',
  });
  const page = await context.newPage();
  await page.goto('/');

  // Key interactive elements should still be operable
  await expect(page.locator('[data-testid="cta-button"]')).toBeVisible();
  await expect(page.locator('nav')).toBeVisible();

  await context.close();
});

Test zoom at 200% and 400% — WCAG 1.4.4 requires content to remain functional up to 400% zoom without horizontal scrolling:

test('content reflows at 400% zoom', async ({ browser }) => {
  const context = await browser.newContext({
    viewport: { width: 320, height: 568 }, // Simulates 400% zoom on 1280px viewport
  });
  const page = await context.newPage();
  await page.goto('/');

  // No horizontal scrollbar
  const hasHorizontalScroll = await page.evaluate(() =>
    document.body.scrollWidth > window.innerWidth
  );
  expect(hasHorizontalScroll).toBe(false);

  await context.close();
});

jest-axe for Unit-Level Accessibility Testing

For component-level testing with Jest and React Testing Library, jest-axe integrates axe-core into your unit test suite. Catch accessibility issues at the component level before they reach integration tests.

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

expect.extend(toHaveNoViolations);

import { Button } from '../components/Button';
import { Modal } from '../components/Modal';
import { LoginForm } from '../components/LoginForm';

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

test('LoginForm has properly labeled inputs', async () => {
  const { container } = render(<LoginForm onSubmit={() => {}} />);
  const results = await axe(container);
  expect(results).toHaveNoViolations();
});

test('Modal has correct ARIA structure', async () => {
  const { container } = render(
    <Modal isOpen={true} onClose={() => {}} title="Confirm Action">
      <p>Are you sure?</p>
    </Modal>
  );
  const results = await axe(container);
  expect(results).toHaveNoViolations();
});

Run axe on icon-only buttons explicitly — these are a common failure point:

test('icon buttons have accessible names', async () => {
  const { container } = render(
    <button aria-label="Close dialog">
      <svg aria-hidden="true" focusable="false">...</svg>
    </button>
  );
  const results = await axe(container);
  expect(results).toHaveNoViolations();
});

Common WCAG Failures and How to Catch Them in CI

These are the violations that appear most frequently in real-world audits:

WCAG Criterion axe Rule ID What Fails
1.1.1 Non-text content image-alt <img> without alt attribute
1.4.3 Contrast (minimum) color-contrast Text with insufficient contrast ratio
2.1.1 Keyboard Manual Interactive elements not keyboard reachable
2.4.3 Focus order Manual Tab order doesn't match visual layout
2.4.7 Focus visible focus-visible No visible focus indicator on focused elements
3.3.2 Labels or instructions label Form inputs without associated labels
4.1.2 Name, role, value aria-required-attr ARIA roles missing required attributes

Wire axe-playwright into your CI pipeline as a dedicated accessibility job. Run it against every page in your application on every PR:

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

on:
  pull_request:
  push:
    branches: [main]

jobs:
  axe-audit:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
      - run: npm ci
      - run: npx playwright install --with-deps chromium
      - run: npm run build
      - run: npx serve -s build &
      - run: npx wait-on http://localhost:3000
      - run: npx playwright test --project=chromium tests/accessibility/
        env:
          BASE_URL: http://localhost:3000

Structure your accessibility test directory to audit every major page:

tests/
  accessibility/
    home.spec.ts
    auth.spec.ts
    dashboard.spec.ts
    settings.spec.ts
    checkout.spec.ts

Set axe to fail builds on critical and serious violations, warn on moderate:

// accessibility/shared.ts
import AxeBuilder from '@axe-core/playwright';
import { Page } from '@playwright/test';

export async function auditPage(page: Page, path: string) {
  await page.goto(path);
  const results = await new AxeBuilder({ page })
    .withTags(['wcag2a', 'wcag2aa', 'wcag21aa'])
    .analyze();

  const critical = results.violations.filter(v =>
    ['critical', 'serious'].includes(v.impact ?? '')
  );
  const warnings = results.violations.filter(v =>
    ['moderate', 'minor'].includes(v.impact ?? '')
  );

  if (warnings.length > 0) {
    console.warn(`[a11y] ${warnings.length} moderate/minor violations on ${path}`);
  }

  return { critical, warnings, incomplete: results.incomplete };
}

Track your accessibility score over time. Store violation counts in CI artifacts and alert when regressions appear. An inaccessibility that ships to production costs 10x more to fix than one caught in a PR check.

HelpMeTest for Ongoing Accessibility Monitoring

Automated CI checks catch regressions on code changes. They don't catch accessibility issues introduced by A/B tests, feature flags, CMS content changes, or third-party script updates — all of which can break accessibility on a live site without touching your codebase.

HelpMeTest runs accessibility audits on a schedule against your live application. Write tests in plain English, powered by Playwright and Robot Framework under the hood, with AI-powered analysis to catch both automated violations and visual accessibility issues.

Example test in HelpMeTest natural language syntax:

Open browser  https://yourapp.com
Check accessibility  WCAG 2.1 AA
Verify no critical violations
Navigate to  /login
Tab through form
Verify focus visible on all inputs
Submit with keyboard
Verify error messages are announced

HelpMeTest runs these on your schedule — hourly, daily, or on every deploy via webhook — and alerts when violations appear. Catch the CMS editor who accidentally removed alt text on a hero image at 2am before users do.

Plans:

  • Free: 10 tests, 24/7 monitoring — enough to cover your critical flows
  • Pro: $100/month, unlimited tests, full accessibility reporting

Start at helpmetest.com.

The Practical Accessibility Testing Checklist

Before shipping any feature with UI:

  • Run jest-axe on new/modified components
  • Run axe-playwright on pages that include the feature
  • Walk through the feature using keyboard only (no mouse)
  • Check color contrast on any new colors or text styles
  • Verify all images have appropriate alt text
  • Verify all form inputs have labels
  • Test at 200% browser zoom
  • Run VoiceOver or NVDA through the primary user flow

In CI:

  • axe audit job runs on every PR
  • Builds fail on critical/serious WCAG violations
  • Violation counts tracked over time

In production:

  • Scheduled accessibility monitoring via HelpMeTest or equivalent
  • Alerts for new violations on live site

Accessibility testing is not a one-time audit. It's a continuous practice — the same as security testing or performance testing. Build it into your pipeline now, before the legal letter arrives.

Read more