React Native Accessibility Testing: axe-core, jest-axe, and Detox

React Native Accessibility Testing: axe-core, jest-axe, and Detox

React Native's cross-platform model means accessibility bugs can appear on iOS, Android, or both, often in different forms. A layered testing approach — jest-native for component tests, react-native-axe-core for runtime audits, and Detox for end-to-end screen reader behavior — catches issues at every level of the stack.

React Native gives you one codebase that runs on iOS and Android, but accessibility is one area where the platforms diverge significantly. An accessibilityLabel that works perfectly with VoiceOver may announce differently with TalkBack. A focus order that feels logical on iOS can be scrambled on Android due to differences in how the two platforms build their accessibility trees.

This guide covers a complete testing stack for React Native accessibility: component-level tests with @testing-library/react-native and jest-native, runtime audits with react-native-axe-core, and end-to-end Detox tests that verify actual screen reader behavior on device.

React Native Accessibility Props Overview

Before writing tests, understand what you're testing. React Native's core accessibility props map to platform accessibility APIs:

React Native prop iOS API Android API
accessibilityLabel accessibilityLabel contentDescription
accessibilityHint accessibilityHint hintText
accessibilityRole accessibilityTraits className / role
accessibilityState accessibilityValue isChecked, isEnabled
accessibilityValue accessibilityValue stateDescription
importantForAccessibility importantForAccessibility
accessibilityViewIsModal accessibilityViewIsModal isAccessibilityFocused

Every meaningful interactive element needs at minimum a correct accessibilityRole and a descriptive accessibilityLabel.

@testing-library/react-native Accessibility Queries

The @testing-library/react-native library exposes accessibility-first queries that mirror how assistive technologies find elements. These should be your default query strategy:

import { render, screen, fireEvent } from '@testing-library/react-native';
import { LoginForm } from '../LoginForm';

describe('LoginForm accessibility', () => {
  it('renders email field with correct accessibility props', () => {
    render(<LoginForm />);

    // Query by accessibility label — this is what VoiceOver/TalkBack reads
    const emailField = screen.getByLabelText('Email address');
    expect(emailField).toBeTruthy();
  });

  it('renders submit button with correct role', () => {
    render(<LoginForm />);

    // getByRole checks accessibilityRole
    const submitButton = screen.getByRole('button', { name: 'Sign In' });
    expect(submitButton).toBeTruthy();
  });

  it('announces form errors accessibly', async () => {
    render(<LoginForm />);

    const submitButton = screen.getByRole('button', { name: 'Sign In' });
    fireEvent.press(submitButton);

    // Error message should be findable by role='alert'
    const errorMessage = await screen.findByRole('alert');
    expect(errorMessage).toBeTruthy();
  });
});

Available query methods include getByLabelText, getByRole, getByHintText, getByDisplayValue, and getByPlaceholderText. Prefer getByRole and getByLabelText — they're the closest to how assistive technologies navigate.

jest-native Accessibility Matchers

@testing-library/jest-native extends Jest with matchers that check accessibility state:

npm install --save-dev @testing-library/jest-native
import '@testing-library/jest-native/extend-expect';
import { render, screen } from '@testing-library/react-native';
import { Toggle } from '../Toggle';

describe('Toggle accessibility states', () => {
  it('announces correct checked state', () => {
    render(<Toggle label="Enable notifications" initialValue={true} />);

    const toggle = screen.getByRole('switch');

    // toHaveAccessibilityState checks accessibilityState prop
    expect(toggle).toHaveAccessibilityState({ checked: true });
  });

  it('announces disabled state', () => {
    render(<Toggle label="Premium feature" disabled />);

    const toggle = screen.getByRole('switch');
    expect(toggle).toHaveAccessibilityState({ disabled: true });
  });

  it('is accessible (has label and role)', () => {
    render(<Toggle label="Dark mode" />);

    const toggle = screen.getByRole('switch');
    // toBeAccessible checks that the element is in the accessibility tree
    // and has a non-empty label
    expect(toggle).toBeVisible();
    expect(toggle).not.toHaveAccessibilityState({ disabled: true });
  });
});

Testing state transitions is particularly important for toggles, checkboxes, and expandable sections:

it('updates checked state when pressed', () => {
  render(<Checkbox label="Accept terms" />);

  const checkbox = screen.getByRole('checkbox');
  expect(checkbox).toHaveAccessibilityState({ checked: false });

  fireEvent.press(checkbox);

  expect(checkbox).toHaveAccessibilityState({ checked: true });
});

Testing accessibilityRole, accessibilityLabel, and accessibilityHint

Each accessibility prop serves a different purpose in screen reader announcements. Test them explicitly:

import { render, screen } from '@testing-library/react-native';
import { ProductCard } from '../ProductCard';

describe('ProductCard accessibility', () => {
  const mockProduct = {
    id: '1',
    name: 'Blue Running Shoes',
    price: 89.99,
    imageUrl: 'https://example.com/shoes.jpg',
    inStock: true,
  };

  it('product image has descriptive label', () => {
    render(<ProductCard product={mockProduct} />);

    // Image should have accessibilityLabel, not just alt text
    const image = screen.getByRole('image', { name: /Blue Running Shoes/i });
    expect(image).toBeTruthy();
  });

  it('add to cart button has hint', () => {
    render(<ProductCard product={mockProduct} />);

    const addButton = screen.getByRole('button', { name: 'Add to cart' });

    // accessibilityHint provides additional context after the label
    // React Native doesn't expose hint as a matcher directly,
    // so check the prop via testID lookup
    expect(addButton.props.accessibilityHint).toBe(
      'Adds Blue Running Shoes to your shopping cart'
    );
  });

  it('price is not interactive but is accessible', () => {
    render(<ProductCard product={mockProduct} />);

    const price = screen.getByText('$89.99');
    // Static text should NOT have a button role
    expect(price.props.accessibilityRole).not.toBe('button');
    // And should be accessible (not hidden from assistive tech)
    expect(price.props.accessible).not.toBe(false);
  });

  it('out of stock products announce state', () => {
    const outOfStock = { ...mockProduct, inStock: false };
    render(<ProductCard product={outOfStock} />);

    const addButton = screen.getByRole('button', { name: 'Add to cart' });
    expect(addButton).toHaveAccessibilityState({ disabled: true });
  });
});

react-native-axe-core Runtime Audits

Component tests verify props, but they don't catch issues that only emerge when components are composed together. react-native-axe-core runs axe accessibility rules against the live React Native component tree at runtime:

npm install --save-dev react-native-axe-core

Enable it in your app's entry point (only in development/test builds):

// index.js
if (__DEV__) {
  const axe = require('react-native-axe-core');
  axe.default();
}

For use in Jest tests with the testing library:

import { render } from '@testing-library/react-native';
import { checkAccessibility } from 'react-native-axe-core/jest';
import { CheckoutScreen } from '../screens/CheckoutScreen';

describe('CheckoutScreen axe audit', () => {
  it('passes axe accessibility checks', async () => {
    const { container } = render(<CheckoutScreen />);

    const results = await checkAccessibility(container);

    // Fail test if any violations found
    expect(results.violations).toHaveLength(0);
  });

  it('reports violations with useful messages', async () => {
    const { container } = render(<CheckoutScreen />);
    const results = await checkAccessibility(container);

    if (results.violations.length > 0) {
      const messages = results.violations.map(v =>
        `${v.id}: ${v.description} (${v.nodes.length} element(s))`
      );
      fail(`Accessibility violations found:\n${messages.join('\n')}`);
    }
  });
});

Detox End-to-End Accessibility Tests

Detox runs real end-to-end tests on iOS simulators and Android emulators. For accessibility, Detox lets you verify that elements are actually reachable and properly announced — not just that the props are set correctly.

// e2e/accessibility.test.ts
import { device, element, by, expect as detoxExpect } from 'detox';

describe('Screen reader accessibility', () => {
  beforeAll(async () => {
    await device.launchApp({
      newInstance: true,
      launchArgs: { detoxEnableSynchronization: 1 },
    });
  });

  it('all form fields are reachable via accessibility', async () => {
    await element(by.text('Log In')).tap();

    // Verify elements exist with correct accessibility attributes
    await detoxExpect(
      element(by.id('email-input'))
    ).toBeVisible();

    // Check that the label is what VoiceOver/TalkBack will announce
    await detoxExpect(
      element(by.label('Email address'))
    ).toBeVisible();

    await detoxExpect(
      element(by.label('Password'))
    ).toBeVisible();
  });

  it('error message is announced after failed login', async () => {
    await element(by.label('Email address')).typeText('invalid');
    await element(by.label('Password')).typeText('wrong');
    await element(by.label('Sign In')).tap();

    // Error should appear and be accessible
    await detoxExpect(
      element(by.id('login-error'))
    ).toBeVisible();

    // On iOS, elements with accessibilityLiveRegion will be announced
    // Verify the error element has the correct identifier
    const errorElement = element(by.id('login-error'));
    await detoxExpect(errorElement).toBeVisible();
  });

  it('modal traps focus correctly', async () => {
    await element(by.label('Show Terms')).tap();

    // Modal should be visible
    await detoxExpect(
      element(by.id('terms-modal'))
    ).toBeVisible();

    // Close button should be accessible inside the modal
    await detoxExpect(
      element(by.label('Close Terms'))
    ).toBeVisible();

    // Dismiss the modal
    await element(by.label('Close Terms')).tap();
    await detoxExpect(
      element(by.id('terms-modal'))
    ).not.toBeVisible();
  });
});

Testing Keyboard Navigation

For users who connect external keyboards to their devices, keyboard navigation needs to work correctly. React Native's TVFocusGuideView (for TV) and tabIndex-equivalent props control this:

it('keyboard tab order follows visual layout', () => {
  render(
    <SearchBar
      onSearch={jest.fn()}
      onFilter={jest.fn()}
      onClear={jest.fn()}
    />
  );

  // Elements should have logical tab indices
  const searchInput = screen.getByLabelText('Search products');
  const filterButton = screen.getByRole('button', { name: 'Filter' });
  const clearButton = screen.getByRole('button', { name: 'Clear search' });

  // Verify accessible elements are present and in the right roles
  expect(searchInput.props.accessibilityRole).toBe('search');
  expect(filterButton.props.accessibilityRole).toBe('button');
  expect(clearButton.props.accessibilityRole).toBe('button');
});

Organizing Your Accessibility Test Suite

A practical structure separates accessibility tests from functional tests while keeping them close to the components they test:

src/
  components/
    Button/
      Button.tsx
      Button.test.tsx          # functional tests
      Button.a11y.test.tsx     # accessibility-specific tests
  screens/
    Checkout/
      CheckoutScreen.tsx
      CheckoutScreen.test.tsx
      CheckoutScreen.a11y.test.tsx
e2e/
  accessibility/
    login.a11y.test.ts
    checkout.a11y.test.ts
    navigation.a11y.test.ts

This separation makes it easy to run accessibility tests in isolation:

# Run only accessibility tests
jest --testPathPattern=<span class="hljs-string">"\.a11y\.test\."

<span class="hljs-comment"># Run accessibility tests with coverage
jest --testPathPattern=<span class="hljs-string">"\.a11y\.test\." --coverage

HelpMeTest can run your Detox accessibility test suite continuously against your staging builds across both iOS and Android simultaneously, giving you cross-platform accessibility regression coverage without maintaining two separate CI pipelines.

The combination of @testing-library/react-native queries, jest-native state matchers, react-native-axe-core runtime audits, and Detox end-to-end tests gives React Native teams a comprehensive accessibility safety net that catches issues at every layer — from individual component props to full screen reader navigation flows on real devices.

Read more