React Accessibility Testing with jest-axe: Unit Testing for A11y

React Accessibility Testing with jest-axe: Unit Testing for A11y

End-to-end accessibility tests catch page-level violations. But component-level accessibility bugs — a missing label on a custom dropdown, an ARIA role on the wrong element, a modal that doesn't trap focus correctly — are best caught in unit tests where feedback is fast and failures are isolated. jest-axe brings the axe-core engine into your Jest test suite, letting you assert accessibility on individual React components using the same tools that power browser extensions and E2E accessibility tests.

Installation

npm install --save-dev jest-axe @testing-library/react @testing-library/jest-dom

If you're using TypeScript:

npm install --save-dev @types/jest-axe

Basic Setup

Configure jest-axe's custom matchers in your Jest setup file:

// jest.setup.js (or setupFilesAfterFramework in jest.config.js)
import { toHaveNoViolations } from 'jest-axe';
expect.extend(toHaveNoViolations);

In your Jest config:

// jest.config.js
module.exports = {
  setupFilesAfterFramework: ['./jest.setup.js'],
  testEnvironment: 'jsdom',
};

Your First Accessibility Test

import React from 'react';
import { render } from '@testing-library/react';
import { axe, toHaveNoViolations } from 'jest-axe';

expect.extend(toHaveNoViolations);

describe('Button component', () => {
  it('should have no accessibility violations', async () => {
    const { container } = render(<button type="button">Save changes</button>);

    const results = await axe(container);

    expect(results).toHaveNoViolations();
  });
});

When this test fails, the error message lists each violation clearly:

expect(received).toHaveNoViolations(expected)

Expected the HTML found at $('button') to have no violations:

  <button>Save</button>

Received:

  "button-name" (moderate): Buttons must have discernible text
    Fix any of the following:
      Element does not have inner text that is visible to screen readers

Testing Components with Interactive States

Static renders catch missing ARIA attributes and structural problems. But some violations only appear in specific states — open menus, error messages, focused elements. Use Testing Library's user interactions to test those:

import React, { useState } from 'react';
import { render, fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { axe } from 'jest-axe';

// Component under test
function Accordion({ title, children }: { title: string; children: React.ReactNode }) {
  const [isOpen, setIsOpen] = useState(false);
  const id = 'accordion-panel';

  return (
    <div>
      <button
        type="button"
        aria-expanded={isOpen}
        aria-controls={id}
        onClick={() => setIsOpen(!isOpen)}
      >
        {title}
      </button>
      <div id={id} hidden={!isOpen} role="region" aria-label={title}>
        {children}
      </div>
    </div>
  );
}

describe('Accordion', () => {
  it('is accessible in collapsed state', async () => {
    const { container } = render(
      <Accordion title="Shipping options">
        <p>Standard shipping: 5-7 days</p>
      </Accordion>
    );

    expect(await axe(container)).toHaveNoViolations();
  });

  it('is accessible in expanded state', async () => {
    const user = userEvent.setup();
    const { container, getByRole } = render(
      <Accordion title="Shipping options">
        <p>Standard shipping: 5-7 days</p>
      </Accordion>
    );

    await user.click(getByRole('button', { name: 'Shipping options' }));

    expect(await axe(container)).toHaveNoViolations();
  });
});

Testing Error States

Form validation errors are a common source of accessibility failures — error messages not associated with fields, missing aria-invalid, missing aria-describedby:

function EmailField() {
  const [value, setValue] = useState('');
  const [error, setError] = useState('');
  const errorId = 'email-error';

  const validate = () => {
    if (!value.includes('@')) {
      setError('Please enter a valid email address');
    } else {
      setError('');
    }
  };

  return (
    <div>
      <label htmlFor="email">Email address</label>
      <input
        id="email"
        type="email"
        value={value}
        onChange={(e) => setValue(e.target.value)}
        onBlur={validate}
        aria-invalid={error ? 'true' : undefined}
        aria-describedby={error ? errorId : undefined}
      />
      {error && (
        <span id={errorId} role="alert">
          {error}
        </span>
      )}
    </div>
  );
}

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

  it('is accessible in error state', async () => {
    const user = userEvent.setup();
    const { container, getByRole } = render(<EmailField />);

    await user.type(getByRole('textbox', { name: 'Email address' }), 'notanemail');
    await user.tab(); // trigger onBlur

    expect(await axe(container)).toHaveNoViolations();
  });
});

Testing Focus State

Focus management is critical for modal dialogs and other components that manipulate focus programmatically:

function Modal({ isOpen, onClose, children }: {
  isOpen: boolean;
  onClose: () => void;
  children: React.ReactNode;
}) {
  const modalRef = React.useRef<HTMLDivElement>(null);

  React.useEffect(() => {
    if (isOpen && modalRef.current) {
      modalRef.current.focus();
    }
  }, [isOpen]);

  if (!isOpen) return null;

  return (
    <div
      role="dialog"
      aria-modal="true"
      aria-label="Settings"
      ref={modalRef}
      tabIndex={-1}
    >
      <button type="button" onClick={onClose} aria-label="Close settings">
        ×
      </button>
      {children}
    </div>
  );
}

describe('Modal', () => {
  it('is accessible when open', async () => {
    const { container } = render(
      <Modal isOpen={true} onClose={() => {}}>
        <p>Modal content</p>
      </Modal>
    );

    expect(await axe(container)).toHaveNoViolations();
  });

  it('is accessible when closed (renders nothing)', async () => {
    const { container } = render(
      <Modal isOpen={false} onClose={() => {}}>
        <p>Modal content</p>
      </Modal>
    );

    expect(await axe(container)).toHaveNoViolations();
  });
});

Custom axe-core Rules

Configure which rules run using the second argument to axe():

// Run only WCAG 2.1 AA rules
const results = await axe(container, {
  runOnly: {
    type: 'tag',
    values: ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'],
  },
});

// Disable a specific rule
const results = await axe(container, {
  rules: {
    'color-contrast': { enabled: false },
  },
});

// Run specific rules only
const results = await axe(container, {
  runOnly: {
    type: 'rule',
    values: ['label', 'aria-required-attr', 'button-name'],
  },
});

You can create a shared axe configuration for your project:

// test-utils/axe-config.ts
import { AxeResults, RunOptions } from 'axe-core';

export const defaultAxeConfig: RunOptions = {
  runOnly: {
    type: 'tag',
    values: ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'],
  },
  rules: {
    // Color contrast requires real browser rendering — skip in jsdom
    'color-contrast': { enabled: false },
  },
};

// Use in tests
const results = await axe(container, defaultAxeConfig);

Note on color-contrast: jsdom doesn't compute actual CSS styles, so color contrast rules may produce unreliable results in unit tests. Disable it here and cover it with E2E accessibility tests (axe-core/Playwright) where a real browser renders styles.

Common React A11y Patterns

aria-label vs aria-labelledby

Use aria-label for elements without a visible label:

// Icon button — no visible text
function IconButton({ onClick, label }: { onClick: () => void; label: string }) {
  return (
    <button type="button" onClick={onClick} aria-label={label}>
      <SearchIcon aria-hidden="true" />
    </button>
  );
}

// Test
it('icon button has accessible label', async () => {
  const { container } = render(
    <IconButton onClick={() => {}} label="Search" />
  );
  expect(await axe(container)).toHaveNoViolations();
});

Use aria-labelledby to reference an existing visible label:

function Section({ id, title, children }: {
  id: string;
  title: string;
  children: React.ReactNode;
}) {
  return (
    <section aria-labelledby={`${id}-title`}>
      <h2 id={`${id}-title`}>{title}</h2>
      {children}
    </section>
  );
}

aria-describedby

Associate helper text and error messages with form fields:

function PasswordField() {
  const helpId = 'password-help';
  return (
    <div>
      <label htmlFor="password">Password</label>
      <input
        id="password"
        type="password"
        aria-describedby={helpId}
        minLength={8}
      />
      <span id={helpId}>
        Minimum 8 characters, including one number
      </span>
    </div>
  );
}

it('password field has accessible description', async () => {
  const { container } = render(<PasswordField />);
  expect(await axe(container)).toHaveNoViolations();
});

ARIA Landmark Roles

Every page needs a logical landmark structure. In component tests, test the landmark structure of layout components:

function AppLayout({ children }: { children: React.ReactNode }) {
  return (
    <>
      <header>
        <nav aria-label="Main navigation">
          {/* nav links */}
        </nav>
      </header>
      <main id="main-content">
        {children}
      </main>
      <footer>
        <nav aria-label="Footer navigation">
          {/* footer links */}
        </nav>
      </footer>
    </>
  );
}

it('app layout has correct landmark structure', async () => {
  const { container } = render(
    <AppLayout>
      <h1>Dashboard</h1>
      <p>Content here</p>
    </AppLayout>
  );
  expect(await axe(container)).toHaveNoViolations();
});

Note: axe-core's region rule requires all content to be inside landmarks. If you have multiple <nav> elements, each must have a unique aria-label or aria-labelledby.

Custom Select / Combobox

Custom dropdowns are notoriously difficult to get right. The ARIA pattern for a combobox is complex:

function ComboBox({ options, label, onChange }: {
  options: string[];
  label: string;
  onChange: (value: string) => void;
}) {
  const [isOpen, setIsOpen] = useState(false);
  const [selected, setSelected] = useState('');
  const [activeIndex, setActiveIndex] = useState(-1);
  const listId = `combobox-list-${label.replace(/\s/g, '-').toLowerCase()}`;

  return (
    <div>
      <label id={`${listId}-label`}>{label}</label>
      <div
        role="combobox"
        aria-expanded={isOpen}
        aria-haspopup="listbox"
        aria-owns={listId}
        aria-labelledby={`${listId}-label`}
      >
        <input
          type="text"
          value={selected}
          readOnly
          aria-autocomplete="list"
          aria-controls={listId}
          aria-activedescendant={
            activeIndex >= 0 ? `${listId}-option-${activeIndex}` : undefined
          }
          onClick={() => setIsOpen(!isOpen)}
        />
      </div>
      <ul
        id={listId}
        role="listbox"
        aria-labelledby={`${listId}-label`}
        hidden={!isOpen}
      >
        {options.map((option, index) => (
          <li
            key={option}
            id={`${listId}-option-${index}`}
            role="option"
            aria-selected={option === selected}
            onClick={() => {
              setSelected(option);
              onChange(option);
              setIsOpen(false);
            }}
          >
            {option}
          </li>
        ))}
      </ul>
    </div>
  );
}

describe('ComboBox', () => {
  it('is accessible when closed', async () => {
    const { container } = render(
      <ComboBox
        options={['Option 1', 'Option 2', 'Option 3']}
        label="Select an option"
        onChange={() => {}}
      />
    );
    expect(await axe(container)).toHaveNoViolations();
  });

  it('is accessible when open', async () => {
    const user = userEvent.setup();
    const { container, getByRole } = render(
      <ComboBox
        options={['Option 1', 'Option 2', 'Option 3']}
        label="Select an option"
        onChange={() => {}}
      />
    );

    await user.click(getByRole('textbox'));
    expect(await axe(container)).toHaveNoViolations();
  });
});

Testing Data Tables

Tables need proper structure for screen reader announcement:

function DataTable({ headers, rows }: {
  headers: string[];
  rows: (string | number)[][];
}) {
  return (
    <table>
      <thead>
        <tr>
          {headers.map((h) => (
            <th key={h} scope="col">{h}</th>
          ))}
        </tr>
      </thead>
      <tbody>
        {rows.map((row, i) => (
          <tr key={i}>
            {row.map((cell, j) => (
              j === 0
                ? <th key={j} scope="row">{cell}</th>
                : <td key={j}>{cell}</td>
            ))}
          </tr>
        ))}
      </tbody>
    </table>
  );
}

it('data table has correct structure', async () => {
  const { container } = render(
    <DataTable
      headers={['Name', 'Status', 'Last Run']}
      rows={[
        ['Login test', 'Passed', '2 hours ago'],
        ['Checkout test', 'Failed', '30 minutes ago'],
      ]}
    />
  );
  expect(await axe(container)).toHaveNoViolations();
});

WCAG 2.1 AA Coverage in Component Tests

jest-axe with the wcag21aa tag set covers these criteria automatically:

WCAG Criterion What axe checks
1.1.1 Non-text content image-alt, input-image-alt
1.3.1 Info and relationships label, aria-required-attr, table
1.3.5 Identify input purpose autocomplete-valid
2.4.3 Focus order scrollable-region-focusable
2.4.4 Link purpose link-name
2.5.3 Label in name label-content-name-mismatch
4.1.1 Parsing duplicate-id-aria
4.1.2 Name, role, value aria-required-attr, aria-valid-attr
4.1.3 Status messages aria-live-region-item

Criteria that require manual or E2E testing:

  • 1.4.3 Color contrast (requires real CSS rendering)
  • 2.1.1 Keyboard — requires actual keyboard interaction testing
  • 2.4.7 Focus visible — requires visual verification
  • 3.3.1 Error identification — partially covered; context determines adequacy

Organizing Accessibility Tests

Co-locate accessibility tests with your component tests, but make them easy to run in isolation:

src/
  components/
    Button/
      Button.tsx
      Button.test.tsx         # includes a11y tests
      Button.a11y.test.tsx    # dedicated a11y tests (optional for complex components)
    Modal/
      Modal.tsx
      Modal.test.tsx
      Modal.a11y.test.tsx

For the dedicated a11y test file approach:

// Modal.a11y.test.tsx
describe('Modal accessibility', () => {
  it.each([
    ['open', true],
    ['closed', false],
  ])('is accessible when %s', async (_, isOpen) => {
    const { container } = render(
      <Modal isOpen={isOpen} onClose={() => {}}>
        <p>Content</p>
      </Modal>
    );
    expect(await axe(container)).toHaveNoViolations();
  });

  it('error state is accessible', async () => {
    const { container } = render(
      <Modal isOpen={true} onClose={() => {}} error="Something went wrong">
        <p>Content</p>
      </Modal>
    );
    expect(await axe(container)).toHaveNoViolations();
  });
});

Run only a11y tests:

jest --testPathPattern="\.a11y\.test\."

Performance Considerations

axe-core analysis is synchronous CPU work in Node.js. For large component trees, it can take 200–500ms per axe() call. Keep tests focused on the component under test — don't render the entire app for component-level a11y tests.

For shared test utilities, create a wrapper that times axe calls:

// test-utils/axe.ts
import { axe, toHaveNoViolations } from 'jest-axe';
expect.extend(toHaveNoViolations);

export async function checkA11y(container: HTMLElement) {
  const start = Date.now();
  const results = await axe(container, {
    runOnly: {
      type: 'tag',
      values: ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'],
    },
  });
  const duration = Date.now() - start;

  if (duration > 1000) {
    console.warn(`axe scan took ${duration}ms — consider scoping to a smaller element`);
  }

  return results;
}

Snapshot Testing for Accessibility

Jest snapshots can track which WCAG violations exist in a component — useful for documenting known violations without making tests permanently fail:

it('documents known accessibility violations', async () => {
  const { container } = render(<LegacyComponent />);
  const results = await axe(container);

  // Snapshot the violation IDs — test fails if new violations appear
  const violationIds = results.violations.map((v) => v.id).sort();
  expect(violationIds).toMatchSnapshot();
});

Update with jest --updateSnapshot only when intentionally changing the accessibility state of a component. This approach is a fallback — the goal is always zero violations.

jest-axe is the fastest way to catch accessibility regressions at the component level. The feedback loop is immediate (component unit test vs. browser E2E test), the violations are isolated to the component under test, and it runs in the same Jest run as your existing tests. Start by adding axe assertions to your most-used shared components — forms, modals, navigation, data tables — and expand coverage as you go.

Read more

Testing Atlantis Terraform PR Automation: Workflows, Plan Verification, and Policy Enforcement

Testing Atlantis Terraform PR Automation: Workflows, Plan Verification, and Policy Enforcement

Atlantis automates Terraform plan and apply through pull requests. But Atlantis itself needs testing: workflow configuration, plan output validation, policy enforcement, and server health checks. This guide covers testing Atlantis workflows locally with atlantis-local, validating plan outputs with custom scripts, enforcing Terraform policies with OPA and Conftest, and monitoring Atlantis

By HelpMeTest