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.
transformIgnorePatternsmust allow packages that ship untranspiled ES modules, or you get a syntax error from insidenode_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());
});| Query | Use when | Preference |
|---|---|---|
getByRole | Buttons, headers, inputs | First choice: matches what a user perceives |
getByLabelText | Form fields with labels | Stable and accessible |
getByText | Visible copy | Breaks on copy change |
getByTestId | No accessible handle exists | Last 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.
Related
Performance optimisation Lists, forms and input handling
Last refreshed 2026-09-18.