React Native Testing Library: Component Tests, Native Module Mocks, and Async Utils
@testing-library/react-native brings the same user-centric testing philosophy to mobile that Testing Library brought to the web. This guide covers component rendering, custom hook testing with renderHook, mocking stubborn native modules, taming async state with waitFor and findBy, and the act() gotchas that trip up even experienced React Native developers.
Key Takeaways
Mock native modules at the module level, not inside tests. Native modules like AsyncStorage or react-native-camera have no JavaScript implementation in a Jest environment — jest.mock() in jest.setup.js is the correct home for these stubs. Prefer findBy over waitFor + getBy. findBy queries are built-in async queries that already wrap waitFor internally; using both is redundant and adds cognitive overhead. renderHook belongs in the same test suite as the components that use the hook. Testing a hook in isolation catches logic bugs; testing it through the component catches integration bugs. act() in React Native wraps async native events, not just state updates. Forgetting act() around fireEvent calls that trigger async side effects is the leading cause of "not wrapped in act()" warnings. Test navigation behavior, not navigator internals. Assert that the correct screen is rendered after interaction, not that navigate() was called — that tests your code, not React Navigation's.
Testing React Native applications introduces a unique set of challenges that don't exist in web testing. Native modules, platform-specific behavior, asynchronous bridge communication, and navigation stacks all need to be accounted for before a single assertion can run. @testing-library/react-native abstracts much of this complexity, but understanding what's happening underneath makes the difference between a test suite you trust and one you fight.
Setting Up @testing-library/react-native
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|@testing-library)/)',
],
};The transformIgnorePatterns entry is critical. Many React Native libraries ship ES module syntax and must be transformed by Babel before Jest can run them. The pattern above whitelists the react-native namespace while leaving everything else untransformed.
Writing Component Tests
The library's core API mirrors the web Testing Library: render, queries, and fireEvent.
// components/LoginForm.tsx
import React, { useState } from 'react';
import { View, TextInput, TouchableOpacity, Text } from 'react-native';
interface Props {
onSubmit: (email: string, password: string) => void;
}
export function LoginForm({ onSubmit }: Props) {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
return (
<View>
<TextInput
testID="email-input"
placeholder="Email"
value={email}
onChangeText={setEmail}
/>
<TextInput
testID="password-input"
placeholder="Password"
secureTextEntry
value={password}
onChangeText={setPassword}
/>
<TouchableOpacity testID="submit-button" onPress={() => onSubmit(email, password)}>
<Text>Log In</Text>
</TouchableOpacity>
</View>
);
}// components/LoginForm.test.tsx
import React from 'react';
import { render, fireEvent } from '@testing-library/react-native';
import { LoginForm } from './LoginForm';
describe('LoginForm', () => {
it('calls onSubmit with email and password when form is submitted', () => {
const onSubmit = jest.fn();
const { getByTestId } = render(<LoginForm onSubmit={onSubmit} />);
fireEvent.changeText(getByTestId('email-input'), 'user@example.com');
fireEvent.changeText(getByTestId('password-input'), 'secret123');
fireEvent.press(getByTestId('submit-button'));
expect(onSubmit).toHaveBeenCalledWith('user@example.com', 'secret123');
});
it('renders email and password inputs', () => {
const { getByPlaceholderText } = render(<LoginForm onSubmit={jest.fn()} />);
expect(getByPlaceholderText('Email')).toBeTruthy();
expect(getByPlaceholderText('Password')).toBeTruthy();
});
});Prefer getByTestId when the element has no meaningful accessible label, but prefer getByRole or getByLabelText when the component is accessible — the latter queries encourage you to build accessible components.
Mocking Native Modules
Native modules are the most common source of test failures in React Native. They exist as native code with a thin JavaScript bridge — there is no JavaScript fallback for Jest to execute.
Mocking AsyncStorage
// jest.setup.js
jest.mock('@react-native-async-storage/async-storage', () =>
require('@react-native-async-storage/async-storage/jest/async-storage-mock')
);The library ships its own mock. Use it instead of writing your own.
Mocking react-native-camera
// __mocks__/react-native-camera.js
export const RNCamera = {
Constants: {
Aspect: {},
BarCodeType: {},
Type: { back: 'back', front: 'front' },
CaptureMode: {},
CaptureTarget: {},
Orientation: {},
FlashMode: { off: 'off', on: 'on', auto: 'auto' },
},
};
export default RNCamera;Place this file in __mocks__/ at the project root. Jest automatically picks up manual mocks from this directory for modules that match the file name.
Mocking react-native-permissions
jest.mock('react-native-permissions', () => ({
check: jest.fn().mockResolvedValue('granted'),
request: jest.fn().mockResolvedValue('granted'),
PERMISSIONS: {
IOS: { CAMERA: 'ios.permission.CAMERA' },
ANDROID: { CAMERA: 'android.permission.CAMERA' },
},
RESULTS: {
GRANTED: 'granted',
DENIED: 'denied',
BLOCKED: 'blocked',
UNAVAILABLE: 'unavailable',
},
}));Mocking Platform-Specific Behavior
import { Platform } from 'react-native';
describe('PlatformSpecificComponent', () => {
it('renders iOS variant', () => {
jest.replaceProperty(Platform, 'OS', 'ios');
const { getByTestId } = render(<PlatformSpecificComponent />);
expect(getByTestId('ios-variant')).toBeTruthy();
});
it('renders Android variant', () => {
jest.replaceProperty(Platform, 'OS', 'android');
const { getByTestId } = render(<PlatformSpecificComponent />);
expect(getByTestId('android-variant')).toBeTruthy();
});
});renderHook for Custom Hooks
renderHook lets you test hooks in isolation without building a throwaway component around them.
// hooks/useUserProfile.ts
import { useState, useEffect } from 'react';
import AsyncStorage from '@react-native-async-storage/async-storage';
interface UserProfile {
name: string;
email: string;
}
export function useUserProfile() {
const [profile, setProfile] = useState<UserProfile | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
AsyncStorage.getItem('user_profile')
.then((data) => {
if (data) setProfile(JSON.parse(data));
})
.catch(setError)
.finally(() => setLoading(false));
}, []);
return { profile, loading, error };
}// hooks/useUserProfile.test.ts
import { renderHook, waitFor } from '@testing-library/react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { useUserProfile } from './useUserProfile';
describe('useUserProfile', () => {
it('loads profile from AsyncStorage', async () => {
const mockProfile = { name: 'Alice', email: 'alice@example.com' };
(AsyncStorage.getItem as jest.Mock).mockResolvedValueOnce(
JSON.stringify(mockProfile)
);
const { result } = renderHook(() => useUserProfile());
expect(result.current.loading).toBe(true);
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.profile).toEqual(mockProfile);
expect(result.current.error).toBeNull();
});
it('handles storage errors gracefully', async () => {
const storageError = new Error('Storage unavailable');
(AsyncStorage.getItem as jest.Mock).mockRejectedValueOnce(storageError);
const { result } = renderHook(() => useUserProfile());
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.error).toBe(storageError);
expect(result.current.profile).toBeNull();
});
});Async Utilities: waitFor, findBy, and act()
waitFor
waitFor retries its callback until it stops throwing, with a default timeout of 1000ms:
import { render, waitFor, fireEvent } from '@testing-library/react-native';
it('shows success message after API call', async () => {
mockApi.post('/login').mockResolvedValueOnce({ token: 'abc' });
const { getByTestId, getByText } = render(<LoginScreen />);
fireEvent.press(getByTestId('submit-button'));
await waitFor(() => {
expect(getByText('Welcome back!')).toBeTruthy();
});
});Increase the timeout for operations that genuinely take longer:
await waitFor(() => expect(getByText('Done')).toBeTruthy(), { timeout: 3000 });findBy Queries
findBy queries combine getBy with waitFor. Use them when querying for elements that appear asynchronously:
it('displays fetched username', async () => {
mockApi.get('/me').mockResolvedValueOnce({ username: 'alice' });
const { findByText } = render(<ProfileScreen />);
// findByText returns a promise — no explicit waitFor needed
const username = await findByText('alice');
expect(username).toBeTruthy();
});act() in React Native
React Native's act() requirement extends beyond state updates. Native events dispatched through fireEvent are synchronous, but any async work they trigger (API calls, timers, state updates from Promises) needs to be wrapped or awaited:
// Wrong — leaves pending state updates after the test
it('submits form', () => {
const { getByTestId } = render(<Form />);
fireEvent.press(getByTestId('submit'));
// async state updates from onPress are not awaited
});
// Correct — await the async consequences
it('submits form', async () => {
const { getByTestId, findByText } = render(<Form />);
fireEvent.press(getByTestId('submit'));
await findByText('Submitted'); // waits for state to settle
});If you see the warning Warning: An update to Foo inside a test was not wrapped in act(...), the fix is almost always to await the result of an async operation rather than wrapping more code in act() manually.
Testing Navigation with React Navigation
The recommended approach is to render a real navigator in tests rather than mocking it:
// test-utils/navigation.tsx
import React from 'react';
import { NavigationContainer } from '@react-navigation/native';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
const Stack = createNativeStackNavigator();
export function renderWithNavigation(
component: React.ComponentType,
options?: { initialRouteName?: string }
) {
const Wrapper = () => (
<NavigationContainer>
<Stack.Navigator initialRouteName={options?.initialRouteName ?? 'Screen'}>
<Stack.Screen name="Screen" component={component} />
<Stack.Screen name="Details" component={DetailsScreen} />
</Stack.Navigator>
</NavigationContainer>
);
return render(<Wrapper />);
}it('navigates to details screen on item press', async () => {
const { getByTestId, findByText } = renderWithNavigation(HomeScreen);
fireEvent.press(getByTestId('item-0'));
// Assert the destination screen rendered, not that navigate() was called
expect(await findByText('Details')).toBeTruthy();
});This approach tests the actual navigation outcome and catches issues like incorrect route names or missing screen registrations.
Common Pitfalls
Forgetting to flush promises between renders. When a component fetches data on mount, always use findBy or waitFor — never assert immediately after render().
Using getByTestId for everything. Overuse of testID leads to tests that don't reflect user behavior. Reach for getByRole, getByText, and getByLabelText first.
Not cleaning up mocks. Use afterEach(() => jest.clearAllMocks()) to prevent mock state from leaking between tests.
Mocking too deep. If you're mocking the internals of a component to test another component, the test is too tightly coupled. Test components through their public API — props and rendered output.
A well-structured React Native test suite gives you confidence that real users can complete their flows, not just that functions return the right values. The patterns above scale from small component tests to full screen integration tests without requiring a physical device.