React Native Testing Library: Unit Tests, Queries, and Async Utilities
React Native Testing Library (RNTL) is the unit and integration testing layer that sits between your Jest unit tests and your Detox/Maestro E2E tests. It renders React Native components in a test environment, provides a DOM-like query API, and gives you control over async behavior — all without a device or simulator.
If you've used Testing Library for React on the web, RNTL will be familiar. The philosophy is the same: test your components the way users interact with them, not the way they're implemented. The differences are in the query vocabulary and some async patterns specific to React Native.
Setup
Install the library and its peer dependencies:
npm install --save-dev @testing-library/react-native @testing-library/jest-nativeConfigure Jest in jest.config.js:
module.exports = {
preset: 'react-native',
setupFilesAfterFramework: ['@testing-library/jest-native/extend-expect'],
transformIgnorePatterns: [
'node_modules/(?!(react-native|@react-native|react-native-.*|@react-navigation)/)',
],
};The transformIgnorePatterns entry is important — React Native modules ship as ES modules and need to be transformed by Babel in the test environment. If you're getting "unexpected token" errors on imports from react-native-* packages, this is the fix.
Your First Test
// components/Counter.tsx
import React, { useState } from 'react';
import { View, Text, TouchableOpacity } from 'react-native';
export function Counter() {
const [count, setCount] = useState(0);
return (
<View>
<Text testID="count-display">{count}</Text>
<TouchableOpacity testID="increment-button" onPress={() => setCount(c => c + 1)}>
<Text>Increment</Text>
</TouchableOpacity>
</View>
);
}// components/Counter.test.tsx
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react-native';
import { Counter } from './Counter';
describe('Counter', () => {
it('starts at zero', () => {
render(<Counter />);
expect(screen.getByTestId('count-display')).toHaveTextContent('0');
});
it('increments when button is pressed', () => {
render(<Counter />);
fireEvent.press(screen.getByTestId('increment-button'));
expect(screen.getByTestId('count-display')).toHaveTextContent('1');
});
it('can increment multiple times', () => {
render(<Counter />);
const button = screen.getByTestId('increment-button');
fireEvent.press(button);
fireEvent.press(button);
fireEvent.press(button);
expect(screen.getByTestId('count-display')).toHaveTextContent('3');
});
});The Screen Object
RNTL v7+ exposes queries via the screen object (imported from @testing-library/react-native). This is preferred over destructuring from render():
// Preferred
render(<MyComponent />);
const element = screen.getByTestId('foo');
// Also works, but not preferred
const { getByTestId } = render(<MyComponent />);
const element = getByTestId('foo');Using screen makes it easier to see what's being queried and avoids having to thread query functions through multiple levels of test setup.
Queries
Queries are the core of RNTL. They find elements in the rendered tree.
Query Variants
Every query exists in six forms:
| Variant | Returns | Throws if missing | Throws if multiple |
|---|---|---|---|
getBy* |
Element | Yes | Yes |
getAllBy* |
Array | Yes | No |
queryBy* |
Element or null | No | Yes |
queryAllBy* |
Array (empty OK) | No | No |
findBy* |
Promise<Element> | Yes (async) | Yes |
findAllBy* |
Promise<Array> | Yes (async) | No |
Use getBy* when you expect the element to be there. Use queryBy* when it might not be (for negative assertions). Use findBy* when the element appears asynchronously.
Available Queries
// By testID — most reliable, matches testID prop
screen.getByTestId('submit-button')
// By visible text — use for user-facing text
screen.getByText('Submit')
screen.getByText(/submit/i) // regex, case-insensitive
// By display value — input values
screen.getByDisplayValue('user@example.com')
// By placeholder text
screen.getByPlaceholderText('Enter email')
// By accessibility label
screen.getByLabelText('Email address')
// By role — semantic queries
screen.getByRole('button', { name: 'Submit' })
screen.getByRole('textbox')
screen.getByRole('checkbox', { checked: true })
// By hint text (accessibility hint)
screen.getByHintText('Double tap to submit the form')Query Priority
The Testing Library philosophy recommends queries in this order, from most to least preferred:
getByRole— most accessible, tests what users and screen readers seegetByLabelText— good for form inputsgetByPlaceholderText— acceptable for inputs without labelsgetByText— fine for non-interactive elementsgetByTestId— use when nothing else works
In practice, React Native components often don't have well-defined ARIA roles, so getByTestId is used more frequently than in web testing. Add testID props liberally — they're stable, fast to query, and explicit.
Custom Matchers
With @testing-library/jest-native configured, you get useful custom matchers:
// Element is visible (not hidden by style)
expect(element).toBeVisible();
expect(element).not.toBeVisible();
// Element is disabled
expect(element).toBeDisabled();
expect(element).toBeEnabled();
// Text content
expect(element).toHaveTextContent('Hello');
expect(element).toHaveTextContent(/hello/i);
// Style
expect(element).toHaveStyle({ color: 'red' });
expect(element).toHaveStyle({ fontWeight: 'bold', fontSize: 16 });
// Prop value
expect(element).toHaveProp('value', 'user@example.com');
expect(element).toHaveProp('editable', false);FireEvent
fireEvent simulates native events on elements:
import { fireEvent } from '@testing-library/react-native';
// Press
fireEvent.press(screen.getByTestId('button'));
// Change text (for TextInput)
fireEvent.changeText(screen.getByTestId('email-input'), 'user@example.com');
// Scroll
fireEvent.scroll(screen.getByTestId('scroll-view'), {
nativeEvent: {
contentOffset: { y: 500 },
contentSize: { height: 1000, width: 400 },
layoutMeasurement: { height: 600, width: 400 },
},
});
// Custom events
fireEvent(element, 'onSwipeLeft');
// With event data
fireEvent(element, 'onSelectionChange', {
nativeEvent: { selection: { start: 0, end: 5 } },
});UserEvent — Realistic User Interactions
RNTL v12+ includes a userEvent API that simulates realistic user interactions rather than firing synthetic events. This catches bugs that fireEvent misses:
import { render, screen, userEvent } from '@testing-library/react-native';
it('types text character by character', async () => {
const user = userEvent.setup();
render(<LoginForm />);
// Types 'hello' one character at a time, triggering
// focus, keyPress, and change events for each character
await user.type(screen.getByTestId('email-input'), 'hello@example.com');
// More realistic press that includes pointerDown, pointerUp
await user.press(screen.getByTestId('submit-button'));
});userEvent operations are async because they simulate timing between events. Use await for all userEvent calls.
Async Testing
React Native components often render asynchronously — data fetches, useEffect hooks, state updates from context. RNTL provides several utilities for async scenarios.
waitFor
waitFor retries an assertion until it passes or times out:
import { render, screen, waitFor } from '@testing-library/react-native';
it('shows user data after fetch', async () => {
render(<UserProfile userId="123" />);
// Initially shows loading state
expect(screen.getByTestId('loading-spinner')).toBeVisible();
// Wait for data to load (default timeout: 1000ms)
await waitFor(() => {
expect(screen.getByText('John Doe')).toBeVisible();
});
// Spinner should be gone
expect(screen.queryByTestId('loading-spinner')).toBeNull();
});For custom timeouts and intervals:
await waitFor(
() => expect(screen.getByText('Success')).toBeVisible(),
{ timeout: 3000, interval: 100 }
);findBy queries
findBy* queries are syntactic sugar for waitFor + getBy*. Use them when you're just waiting for an element to appear:
// This
const element = await screen.findByText('Loaded data');
// Is equivalent to
await waitFor(() => screen.getByText('Loaded data'));
const element = screen.getByText('Loaded data');act()
React Native updates are batched and require act() to flush. RNTL wraps most of its APIs in act() automatically, but you need it when triggering async state updates manually:
import { act } from '@testing-library/react-native';
it('updates state after timer', async () => {
jest.useFakeTimers();
render(<TimedComponent />);
await act(async () => {
jest.advanceTimersByTime(3000);
});
expect(screen.getByText('Timer complete')).toBeVisible();
jest.useRealTimers();
});Mocking
Mocking API Calls
Use jest.mock or msw for network requests:
// With jest.mock
jest.mock('../api/users', () => ({
fetchUser: jest.fn().mockResolvedValue({
id: '123',
name: 'John Doe',
email: 'john@example.com',
}),
}));
it('displays user data', async () => {
render(<UserProfile userId="123" />);
expect(await screen.findByText('John Doe')).toBeVisible();
});With msw (recommended for complex API scenarios):
import { setupServer } from 'msw/node';
import { rest } from 'msw';
const server = setupServer(
rest.get('https://api.example.com/users/:id', (req, res, ctx) => {
return res(ctx.json({ name: 'John Doe', email: 'john@example.com' }));
})
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
it('displays user profile', async () => {
render(<UserProfile userId="123" />);
expect(await screen.findByText('John Doe')).toBeVisible();
});
it('handles API error', async () => {
server.use(
rest.get('https://api.example.com/users/:id', (req, res, ctx) => {
return res(ctx.status(500));
})
);
render(<UserProfile userId="123" />);
expect(await screen.findByText('Failed to load user')).toBeVisible();
});Mocking Navigation
If your component uses React Navigation, wrap it in a mock navigator:
import { NavigationContainer } from '@react-navigation/native';
import { createStackNavigator } from '@react-navigation/stack';
it('navigates on button press', async () => {
const navigate = jest.fn();
jest.mock('@react-navigation/native', () => ({
...jest.requireActual('@react-navigation/native'),
useNavigation: () => ({ navigate }),
}));
render(<ProductCard productId="42" />);
fireEvent.press(screen.getByTestId('view-details-button'));
expect(navigate).toHaveBeenCalledWith('ProductDetail', { productId: '42' });
});Mocking Context
Wrap components in context providers for testing:
import { CartProvider } from '../context/CartContext';
function renderWithCart(ui, { cartItems = [] } = {}) {
return render(
<CartProvider initialItems={cartItems}>
{ui}
</CartProvider>
);
}
it('shows correct cart count', () => {
renderWithCart(<CartIcon />, {
cartItems: [
{ id: '1', name: 'Product A', quantity: 2 },
{ id: '2', name: 'Product B', quantity: 1 },
],
});
expect(screen.getByTestId('cart-badge')).toHaveTextContent('3');
});Integration Tests
RNTL shines for integration tests — rendering multiple connected components together with real context and navigation, mocking only the network boundary.
// Test a complete checkout flow at the component level
it('completes checkout flow', async () => {
const user = userEvent.setup();
render(
<StoreProvider>
<NavigationContainer>
<CheckoutStack />
</NavigationContainer>
</StoreProvider>
);
// Cart screen
expect(screen.getByTestId('cart-screen')).toBeVisible();
expect(screen.getByText('2 items')).toBeVisible();
await user.press(screen.getByTestId('proceed-to-checkout'));
// Payment screen
expect(await screen.findByTestId('payment-screen')).toBeVisible();
await user.type(screen.getByTestId('card-number'), '4242424242424242');
await user.type(screen.getByTestId('expiry'), '12/26');
await user.type(screen.getByTestId('cvv'), '123');
await user.press(screen.getByTestId('pay-button'));
// Confirmation
expect(await screen.findByTestId('order-confirmation')).toBeVisible();
expect(screen.getByText(/order #\d+/i)).toBeVisible();
});This kind of test gives you high confidence that the component layer works correctly — routing, state management, and UI all exercised together — without the infrastructure overhead of a full E2E test.
Snapshot Testing
RNTL supports snapshot testing, but use it with caution:
it('renders correctly', () => {
const tree = render(<ProductCard name="Widget" price={29.99} />).toJSON();
expect(tree).toMatchSnapshot();
});Snapshot tests catch unintended rendering changes, but they break constantly on intentional changes and tend to get blindly updated without review. They're most useful for stable, pure presentational components. Don't use them for components with complex state or async behavior.
Testing Hooks
For custom hooks, use renderHook:
import { renderHook, act } from '@testing-library/react-native';
import { useCart } from './useCart';
it('adds items to cart', () => {
const { result } = renderHook(() => useCart());
expect(result.current.items).toHaveLength(0);
act(() => {
result.current.addItem({ id: '1', name: 'Widget', price: 29.99 });
});
expect(result.current.items).toHaveLength(1);
expect(result.current.total).toBe(29.99);
});
it('handles async data fetch', async () => {
const { result } = renderHook(() => useProductData('123'));
expect(result.current.loading).toBe(true);
await act(async () => {
await waitFor(() => expect(result.current.loading).toBe(false));
});
expect(result.current.product).toEqual({ id: '123', name: 'Widget' });
});Where RNTL Fits in Your Test Strategy
React Native testing has three layers:
- Unit tests (Jest) — pure functions, business logic, reducers, selectors. No rendering.
- Component/integration tests (RNTL) — components, screens, flows. Real rendering, mocked network.
- E2E tests (Detox/Maestro) — complete app flows on a real device or simulator.
RNTL occupies the middle layer. It runs fast (no device needed), gives you high confidence in component behavior, and catches a large class of bugs before they reach E2E testing.
A reasonable distribution for a mature React Native codebase:
- 60-70% unit tests (fast, zero overhead)
- 20-30% RNTL integration tests (fast, reliable)
- 5-10% E2E tests (slow, expensive, high value for critical paths)
Complementing with Production Monitoring
RNTL, Detox, and Maestro all run in controlled environments. Production is different — real API latency, real device variations, real OS updates. Bugs that slip through your test pyramid show up as user reports.
HelpMeTest monitors your production app continuously, running real flows on real devices and alerting when something breaks. It's the final layer that catches what testing misses — not because the tests were wrong, but because production is genuinely different.
Summary
React Native Testing Library is the right tool for testing React Native components without a device. The key practices:
- Use
screen.getByTestId()for stable selectors in complex components,getByRole()andgetByText()for user-facing assertions - Use
findBy*queries for async elements,queryBy*for negative assertions - Use
userEventoverfireEventfor realistic interaction simulation - Mock at the network boundary with
msw, not at the module level - Write integration tests that exercise complete flows through your component tree
- Reserve E2E tests for critical paths — keep the bulk of coverage in RNTL
The investment in RNTL tests pays off quickly: they run in seconds, don't require device infrastructure, and catch most of the bugs that would otherwise reach E2E testing or production.