Testing React Native apps

Configure Jest and React Native Testing Library, mock native modules correctly, test async flows, avoid snapshot traps, and add Detox for end-to-end coverage.

Jest and Testing Library

// jest.setup.js
import '@testing-library/jest-native/extend-expect';
import mockAsyncStorage from '@react-native-async-storage/async-storage/jest/async-storage-mock';
import mockSafeAreaContext from 'react-native-safe-area-context/jest/mock';

jest.mock('@react-native-async-storage/async-storage', () => mockAsyncStorage);
jest.mock('react-native-safe-area-context', () => mockSafeAreaContext);
jest.mock('react-native/Libraries/Animated/NativeAnimatedHelper');

// jest.config.js
module.exports = {
  preset: 'react-native',
  setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
  transformIgnorePatterns: [
    'node_modules/(?!((jest-)?react-native|@react-native(-community)?|@react-navigation)/)',
  ],
};
  • Mock native modules at the boundary in a setup file rather than inside each test; the module registry is global.
  • transformIgnorePatterns must allow packages that ship untranspiled ES modules, or you get a syntax error from inside node_modules.
  • Never mock the component you are testing — mock its dependencies.
  • Reset mocks between tests so the order of execution cannot change the outcome.

Testing behaviour, not implementation

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

test('shows an error when the email is invalid', async () => {
  const onSubmit = jest.fn();
  render(<SignUpForm onSubmit={onSubmit} />);

  fireEvent.changeText(screen.getByLabelText('Email'), 'not-an-email');
  fireEvent.press(screen.getByRole('button', { name: 'Create account' }));

  expect(await screen.findByText('Invalid email')).toBeOnTheScreen();
  expect(onSubmit).not.toHaveBeenCalled();
});

test('loads and renders articles', async () => {
  jest.spyOn(api, 'listArticles').mockResolvedValue([
    { id: '1', title: 'First', subtitle: 's' },
  ]);

  render(<ArticleScreen />);

  expect(screen.getByTestId('loading')).toBeOnTheScreen();
  expect(await screen.findByText('First')).toBeOnTheScreen();
  await waitFor(() => expect(screen.queryByTestId('loading')).toBeNull());
});
QueryUse whenPreference
getByRoleButtons, headers, inputsFirst choice: matches what a user perceives
getByLabelTextForm fields with labelsStable and accessible
getByTextVisible copyBreaks on copy change
getByTestIdNo accessible handle existsLast resort

Detox end-to-end and snapshot discipline

// e2e/checkout.e2e.js
describe('checkout', () => {
  beforeAll(async () => {
    await device.launchApp({ newInstance: true, permissions: { notifications: 'YES' } });
  });

  it('adds an item and reaches the payment screen', async () => {
    await element(by.id('add-to-cart')).tap();
    await expect(element(by.id('cart-count'))).toHaveText('1');
    await element(by.id('go-to-checkout')).tap();
    await expect(element(by.id('payment-title'))).toBeVisible();
  });
});
⚠️
A snapshot test fails on any markup change and is usually regenerated without being read, which means it verifies nothing while adding noise. Prefer an explicit assertion on what the user should see.

FAQ

Why does my test fail on an animation?
Animated values do not settle. Disable animations in the test environment, or use fake timers and advance them explicitly instead of waiting for real time to pass.
Do I need both Testing Library and Detox?
They answer different questions. Testing Library verifies a component's behaviour in milliseconds; Detox verifies the shipped app on a real device with real navigation and native modules. Keep the Detox suite small and focused on critical paths.

Performance optimisation Lists, forms and input handling

Last refreshed 2026-09-18.