Testing Accessibility in React, Vue, and Angular Components

Testing Accessibility in React, Vue, and Angular Components

Component-level accessibility testing is where you prevent accessibility bugs from ever reaching the browser. By testing at the unit and integration level — before a real page renders — you catch missing ARIA attributes, broken focus management, and silent live regions while the component code is right in front of you.

This guide covers the full stack: jest-axe with React Testing Library, ARIA attribute assertions, focus trap testing, dynamic announcement testing with aria-live, Vue component accessibility testing, and Angular CDK testing.

React Accessibility Testing with jest-axe

jest-axe wraps axe-core as a Jest custom matcher. You render a component, run the axe audit against the rendered DOM, and assert on violations.

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

Basic setup:

// jest.setup.js
import { toHaveNoViolations } from 'jest-axe';
expect.extend(toHaveNoViolations);
// jest.config.js
module.exports = {
  setupFilesAfterFramework: ['./jest.setup.js']
};

First test:

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

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

  it('has no violations in disabled state', async () => {
    const { container } = render(
      <Button onClick={() => {}} disabled>Submit</Button>
    );
    const results = await axe(container);
    expect(results).toHaveNoViolations();
  });
});

Testing ARIA Attributes

axe-core catches invalid ARIA, but asserting the right ARIA attributes exist and carry the right values is equally important. React Testing Library's getByRole queries use ARIA semantics, which makes ARIA testing natural.

import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Accordion } from './Accordion';

describe('Accordion', () => {
  const items = [
    { id: '1', title: 'Section 1', content: 'Content for section 1' },
    { id: '2', title: 'Section 2', content: 'Content for section 2' }
  ];

  it('renders with correct ARIA attributes when collapsed', () => {
    render(<Accordion items={items} />);

    const button = screen.getByRole('button', { name: 'Section 1' });
    expect(button).toHaveAttribute('aria-expanded', 'false');
    expect(button).toHaveAttribute('aria-controls', 'accordion-panel-1');

    const panel = document.getElementById('accordion-panel-1');
    expect(panel).toHaveAttribute('role', 'region');
    expect(panel).toHaveAttribute('aria-labelledby', button.id);
    expect(panel).not.toBeVisible(); // or toHaveAttribute('hidden')
  });

  it('updates aria-expanded when opened', async () => {
    const user = userEvent.setup();
    render(<Accordion items={items} />);

    const button = screen.getByRole('button', { name: 'Section 1' });
    await user.click(button);

    expect(button).toHaveAttribute('aria-expanded', 'true');
  });

  it('collapses on second click', async () => {
    const user = userEvent.setup();
    render(<Accordion items={items} />);

    const button = screen.getByRole('button', { name: 'Section 1' });
    await user.click(button);
    await user.click(button);

    expect(button).toHaveAttribute('aria-expanded', 'false');
  });
});

Testing custom toggle buttons:

import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ToggleButton } from './ToggleButton';

describe('ToggleButton', () => {
  it('reflects pressed state via aria-pressed', async () => {
    const user = userEvent.setup();
    render(<ToggleButton label="Bold" />);

    const button = screen.getByRole('button', { name: 'Bold' });
    expect(button).toHaveAttribute('aria-pressed', 'false');

    await user.click(button);
    expect(button).toHaveAttribute('aria-pressed', 'true');
  });

  it('is activatable by keyboard', async () => {
    const user = userEvent.setup();
    render(<ToggleButton label="Bold" />);

    const button = screen.getByRole('button', { name: 'Bold' });
    button.focus();
    await user.keyboard('{Space}');

    expect(button).toHaveAttribute('aria-pressed', 'true');
  });
});

Testing Focus Management in Modals

Modal dialogs are a common source of accessibility bugs. They require:

  • Focus to move into the modal when it opens
  • Focus to be trapped inside while the modal is open
  • Focus to return to the trigger when the modal closes
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { axe } from 'jest-axe';
import { Modal } from './Modal';

describe('Modal focus management', () => {
  it('moves focus to the modal when opened', async () => {
    const user = userEvent.setup();
    const { container } = render(
      <div>
        <button id="open-modal">Open</button>
        <Modal trigger="#open-modal" title="Test Modal">
          <p>Modal content</p>
          <button>Action</button>
          <button>Cancel</button>
        </Modal>
      </div>
    );

    await user.click(screen.getByText('Open'));

    await waitFor(() => {
      const dialog = screen.getByRole('dialog');
      // Focus should be on the dialog or the first focusable element inside it
      expect(dialog).toContainElement(document.activeElement);
    });
  });

  it('traps focus within the modal', async () => {
    const user = userEvent.setup();
    render(
      <Modal isOpen title="Test Modal">
        <button>First</button>
        <button>Second</button>
        <button>Close</button>
      </Modal>
    );

    const buttons = screen.getAllByRole('button');
    // Tab through all buttons
    buttons[0].focus();
    await user.tab();
    expect(buttons[1]).toHaveFocus();
    await user.tab();
    expect(buttons[2]).toHaveFocus();

    // Tab from last element should wrap to first element inside modal
    await user.tab();
    expect(buttons[0]).toHaveFocus();
  });

  it('returns focus to trigger on close', async () => {
    const user = userEvent.setup();
    render(
      <div>
        <button id="trigger">Open modal</button>
        <Modal triggerId="trigger" title="Test Modal">
          <button>Close</button>
        </Modal>
      </div>
    );

    const trigger = screen.getByText('Open modal');
    await user.click(trigger);

    const closeButton = screen.getByText('Close');
    await user.click(closeButton);

    await waitFor(() => {
      expect(trigger).toHaveFocus();
    });
  });

  it('closes on Escape key', async () => {
    const user = userEvent.setup();
    const onClose = jest.fn();
    render(
      <Modal isOpen onClose={onClose} title="Test Modal">
        <p>Content</p>
      </Modal>
    );

    await user.keyboard('{Escape}');
    expect(onClose).toHaveBeenCalledTimes(1);
  });

  it('has no accessibility violations when open', async () => {
    const { container } = render(
      <Modal isOpen title="Confirmation">
        <p>Are you sure?</p>
        <button>Confirm</button>
        <button>Cancel</button>
      </Modal>
    );

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

Testing Dynamic Content Announcements (aria-live)

aria-live regions broadcast DOM changes to screen readers. Testing them requires asserting that the live region element is present and receives the right content at the right time.

import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { SearchForm } from './SearchForm';

describe('SearchForm announcements', () => {
  it('announces loading state to screen readers', async () => {
    const user = userEvent.setup();
    render(<SearchForm />);

    const input = screen.getByRole('searchbox');
    await user.type(input, 'test query');
    await user.keyboard('{Enter}');

    // aria-live="polite" region should announce loading
    const liveRegion = screen.getByRole('status');
    await waitFor(() => {
      expect(liveRegion).toHaveTextContent('Searching...');
    });
  });

  it('announces results count after search completes', async () => {
    const user = userEvent.setup();
    render(<SearchForm />);

    const input = screen.getByRole('searchbox');
    await user.type(input, 'test query');
    await user.keyboard('{Enter}');

    const liveRegion = screen.getByRole('status');
    await waitFor(() => {
      expect(liveRegion).toHaveTextContent(/\d+ results found/);
    });
  });

  it('announces error messages with role=alert', async () => {
    const user = userEvent.setup();
    render(<SearchForm maxLength={5} />);

    const input = screen.getByRole('searchbox');
    await user.type(input, 'toolongquery');

    // role="alert" maps to aria-live="assertive"
    const alertRegion = screen.getByRole('alert');
    expect(alertRegion).toHaveTextContent('Search query too long');
  });
});

Testing a notification/toast system:

import { render, screen, act } from '@testing-library/react';
import { ToastProvider, useToast } from './Toast';

function TestComponent() {
  const toast = useToast();
  return (
    <button onClick={() => toast.success('File saved successfully')}>
      Save
    </button>
  );
}

describe('Toast notifications', () => {
  it('renders success message in a live region', async () => {
    const user = userEvent.setup();
    render(
      <ToastProvider>
        <TestComponent />
      </ToastProvider>
    );

    await user.click(screen.getByText('Save'));

    const liveRegion = screen.getByRole('status');
    expect(liveRegion).toHaveTextContent('File saved successfully');
  });

  it('uses role=alert for error toasts', async () => {
    render(<ToastProvider><div /></ToastProvider>);

    act(() => {
      // Simulate an error toast
      document.querySelector('[role="alert"]') || (() => {
        const alert = document.createElement('div');
        alert.setAttribute('role', 'alert');
        alert.textContent = 'Upload failed';
        document.body.appendChild(alert);
      })();
    });

    expect(screen.getByRole('alert')).toHaveTextContent('Upload failed');
  });
});

Vue Accessibility Testing

Vue component testing uses @vue/test-utils and can integrate axe-core directly.

npm install --save-dev @vue/test-utils vitest jsdom axe-core

Testing ARIA with @vue/test-utils

// Button.spec.js
import { mount } from '@vue/test-utils';
import { axe, toHaveNoViolations } from 'jest-axe';
import Button from './Button.vue';

expect.extend(toHaveNoViolations);

describe('Button.vue', () => {
  it('has no accessibility violations', async () => {
    const wrapper = mount(Button, {
      props: { label: 'Submit' }
    });

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

  it('sets aria-disabled when disabled prop is true', () => {
    const wrapper = mount(Button, {
      props: { label: 'Submit', disabled: true }
    });

    const button = wrapper.find('button');
    expect(button.attributes('aria-disabled')).toBe('true');
    // Or if using native disabled:
    expect(button.attributes('disabled')).toBeDefined();
  });
});

Vue dropdown component test:

// Dropdown.spec.js
import { mount } from '@vue/test-utils';
import Dropdown from './Dropdown.vue';

describe('Dropdown.vue', () => {
  it('has correct ARIA for closed state', () => {
    const wrapper = mount(Dropdown, {
      props: {
        label: 'Options',
        items: ['Edit', 'Delete', 'Share']
      }
    });

    const trigger = wrapper.find('[data-testid="dropdown-trigger"]');
    expect(trigger.attributes('aria-haspopup')).toBe('listbox');
    expect(trigger.attributes('aria-expanded')).toBe('false');
  });

  it('updates aria-expanded when opened', async () => {
    const wrapper = mount(Dropdown, {
      props: {
        label: 'Options',
        items: ['Edit', 'Delete', 'Share']
      }
    });

    await wrapper.find('[data-testid="dropdown-trigger"]').trigger('click');

    expect(
      wrapper.find('[data-testid="dropdown-trigger"]').attributes('aria-expanded')
    ).toBe('true');
  });

  it('navigates options with arrow keys', async () => {
    const wrapper = mount(Dropdown, {
      props: {
        label: 'Options',
        items: ['Edit', 'Delete', 'Share']
      }
    });

    await wrapper.find('[data-testid="dropdown-trigger"]').trigger('click');
    await wrapper.find('[data-testid="dropdown-trigger"]').trigger('keydown', {
      key: 'ArrowDown'
    });

    const options = wrapper.findAll('[role="option"]');
    expect(options[0].classes()).toContain('focused');
    expect(
      wrapper.find('[data-testid="dropdown-trigger"]').attributes('aria-activedescendant')
    ).toBe(options[0].attributes('id'));
  });

  it('closes on Escape', async () => {
    const wrapper = mount(Dropdown, {
      props: { label: 'Options', items: ['Edit'] }
    });

    await wrapper.find('[data-testid="dropdown-trigger"]').trigger('click');
    await wrapper.trigger('keydown', { key: 'Escape' });

    expect(
      wrapper.find('[data-testid="dropdown-trigger"]').attributes('aria-expanded')
    ).toBe('false');
  });
});

Testing Vue Form Components

// FormField.spec.js
import { mount } from '@vue/test-utils';
import { axe, toHaveNoViolations } from 'jest-axe';
import FormField from './FormField.vue';

expect.extend(toHaveNoViolations);

describe('FormField.vue', () => {
  it('associates label with input', () => {
    const wrapper = mount(FormField, {
      props: { id: 'email', label: 'Email address' }
    });

    const label = wrapper.find('label');
    const input = wrapper.find('input');

    expect(label.attributes('for')).toBe('email');
    expect(input.attributes('id')).toBe('email');
  });

  it('shows error message with aria-describedby', async () => {
    const wrapper = mount(FormField, {
      props: {
        id: 'email',
        label: 'Email address',
        error: 'Enter a valid email'
      }
    });

    const input = wrapper.find('input');
    const errorId = `${wrapper.props('id')}-error`;

    expect(input.attributes('aria-describedby')).toContain(errorId);
    expect(input.attributes('aria-invalid')).toBe('true');
    expect(wrapper.find(`#${errorId}`).text()).toBe('Enter a valid email');
  });

  it('has no accessibility violations in error state', async () => {
    const wrapper = mount(FormField, {
      props: {
        id: 'email',
        label: 'Email address',
        error: 'Enter a valid email'
      }
    });

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

Angular Accessibility Testing

Angular provides @angular/cdk/testing with HarnessLoader for component interaction, and the standard DOM testing APIs work well for ARIA assertions.

npm install --save-dev @angular/cdk/testing @testing-library/angular jest-axe

Angular Component Harness Testing

// button.component.spec.ts
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { HarnessLoader } from '@angular/cdk/testing';
import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed';
import { MatButtonHarness } from '@angular/material/button/testing';
import { axe, toHaveNoViolations } from 'jest-axe';
import { ButtonComponent } from './button.component';

expect.extend(toHaveNoViolations);

describe('ButtonComponent', () => {
  let fixture: ComponentFixture<ButtonComponent>;
  let loader: HarnessLoader;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      declarations: [ButtonComponent]
    }).compileComponents();

    fixture = TestBed.createComponent(ButtonComponent);
    loader = TestbedHarnessEnvironment.loader(fixture);
    fixture.detectChanges();
  });

  it('has no accessibility violations', async () => {
    const results = await axe(fixture.nativeElement);
    expect(results).toHaveNoViolations();
  });

  it('is focusable via keyboard', async () => {
    const button = await loader.getHarness(MatButtonHarness);
    await button.focus();
    expect(await button.isFocused()).toBe(true);
  });
});

Angular Form Accessibility

// contact-form.component.spec.ts
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ReactiveFormsModule } from '@angular/forms';
import { By } from '@angular/platform-browser';
import { axe, toHaveNoViolations } from 'jest-axe';
import { ContactFormComponent } from './contact-form.component';

expect.extend(toHaveNoViolations);

describe('ContactFormComponent accessibility', () => {
  let fixture: ComponentFixture<ContactFormComponent>;
  let component: ContactFormComponent;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      declarations: [ContactFormComponent],
      imports: [ReactiveFormsModule]
    }).compileComponents();

    fixture = TestBed.createComponent(ContactFormComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
  });

  it('has no violations in initial state', async () => {
    const results = await axe(fixture.nativeElement);
    expect(results).toHaveNoViolations();
  });

  it('shows accessible error messages on submit', async () => {
    // Submit without filling required fields
    const form = fixture.debugElement.query(By.css('form'));
    form.nativeElement.dispatchEvent(new Event('submit'));
    fixture.detectChanges();

    const emailInput = fixture.debugElement.query(By.css('[formControlName="email"]'));
    const emailEl = emailInput.nativeElement;

    expect(emailEl.getAttribute('aria-invalid')).toBe('true');
    expect(emailEl.getAttribute('aria-describedby')).toBeTruthy();

    const errorId = emailEl.getAttribute('aria-describedby');
    const errorEl = fixture.debugElement.query(By.css(`#${errorId}`));
    expect(errorEl.nativeElement.textContent).toContain('Email is required');
  });

  it('has no violations in error state', async () => {
    const form = fixture.debugElement.query(By.css('form'));
    form.nativeElement.dispatchEvent(new Event('submit'));
    fixture.detectChanges();

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

Angular Dialog Focus Management

// dialog.component.spec.ts
import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
import { MatDialogModule, MatDialog } from '@angular/material/dialog';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { ConfirmDialogComponent } from './confirm-dialog.component';

describe('ConfirmDialogComponent focus management', () => {
  let dialog: MatDialog;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      declarations: [ConfirmDialogComponent],
      imports: [MatDialogModule, NoopAnimationsModule]
    }).compileComponents();

    dialog = TestBed.inject(MatDialog);
  });

  it('focuses the dialog when opened', fakeAsync(() => {
    const dialogRef = dialog.open(ConfirmDialogComponent, {
      data: { message: 'Confirm deletion?' }
    });

    tick(0);

    const dialogElement = document.querySelector('mat-dialog-container');
    expect(dialogElement).not.toBeNull();
    // Material dialog manages focus automatically — verify it's within the container
    expect(dialogElement!.contains(document.activeElement)).toBe(true);

    dialogRef.close();
    tick(0);
  }));

  it('returns focus on close', fakeAsync(() => {
    const trigger = document.createElement('button');
    trigger.textContent = 'Open';
    document.body.appendChild(trigger);
    trigger.focus();

    const dialogRef = dialog.open(ConfirmDialogComponent, {
      data: { message: 'Confirm?' }
    });
    tick(0);

    dialogRef.close();
    tick(0);

    expect(document.activeElement).toBe(trigger);
    document.body.removeChild(trigger);
  }));
});

Testing Live Region Updates in Components

A common pattern: a React/Vue/Angular component that updates an aria-live region when async data loads. Test the sequence, not just the final state.

// React example: DataTable with live announcements
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { DataTable } from './DataTable';
import { server } from '../mocks/server'; // msw mock server
import { rest } from 'msw';

describe('DataTable live announcements', () => {
  it('announces loading then results', async () => {
    const user = userEvent.setup();
    render(<DataTable />);

    // Find the live region early — it must exist before content is added
    // for screen readers to reliably announce changes
    const liveRegion = screen.getByRole('status');
    expect(liveRegion).toBeInTheDocument();

    await user.click(screen.getByRole('button', { name: 'Load data' }));

    // Should announce loading
    expect(liveRegion).toHaveTextContent('Loading...');

    // Should announce completion
    await waitFor(() => {
      expect(liveRegion).toHaveTextContent('25 rows loaded');
    });
  });

  it('announces error state', async () => {
    server.use(
      rest.get('/api/data', (req, res, ctx) => res(ctx.status(500)))
    );

    const user = userEvent.setup();
    render(<DataTable />);

    await user.click(screen.getByRole('button', { name: 'Load data' }));

    // Errors should use role="alert" for immediate announcement
    await waitFor(() => {
      expect(screen.getByRole('alert')).toHaveTextContent('Failed to load data');
    });
  });
});

Common Accessibility Bugs in Component Code

The patterns that generate violations most frequently:

Icon buttons without names:

// FAIL
<button onClick={close}><CloseIcon /></button>

// PASS
<button onClick={close} aria-label="Close dialog"><CloseIcon /></button>

Form inputs missing labels:

// FAIL
<input type="search" placeholder="Search..." onChange={...} />

// PASS
<label htmlFor="search">Search</label>
<input id="search" type="search" placeholder="Search..." onChange={...} />

// Or using aria-label when visual label is not desired
<input
  type="search"
  aria-label="Search products"
  placeholder="Search..."
  onChange={...}
/>

Role violations on non-interactive elements:

// FAIL: onClick on a div without role or keyboard handler
<div className="card" onClick={navigate}>...</div>

// PASS: use a button or anchor, or add complete keyboard support
<button className="card" onClick={navigate}>...</button>

Missing aria-expanded on disclosure widgets:

// FAIL: open state only communicated visually
<button onClick={toggle}>
  {open ? 'Hide' : 'Show'} details
</button>

// PASS
<button onClick={toggle} aria-expanded={open} aria-controls="details-panel">
  {open ? 'Hide' : 'Show'} details
</button>
<div id="details-panel" hidden={!open}>
  ...
</div>

Component accessibility testing is not a separate phase. Write the axe assertion alongside every other assertion in your component tests. If every component ships with a passing toHaveNoViolations() assertion, your integration-level and E2E accessibility pass rate will be dramatically higher before you even run a browser-level audit.

Read more

Start now free