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-nativeimport '@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-coreEnable 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.tsThis 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\." --coverageHelpMeTest 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.