React Native Unit Testing with Jest: Components, Hooks, and Navigation
Unit tests are the foundation of a React Native test strategy. They run fast, give immediate feedback, and catch logic errors long before they reach a device or simulator. Yet many React Native teams skip them or write tests that test nothing meaningful.
This guide covers the practical patterns you need: testing components, hooks, async operations, and navigation in a React Native project using Jest.
Setup
React Native projects created with the CLI already include Jest. Check package.json:
{
"jest": {
"preset": "react-native"
}
}Install React Native Testing Library, which provides the rendering utilities:
npm install --save-dev @testing-library/react-native @testing-library/jest-nativeAdd the custom matchers to your setup file:
// jest.setup.js
import '@testing-library/jest-native/extend-expect';Update package.json:
{
"jest": {
"preset": "react-native",
"setupFilesAfterFramework": ["./jest.setup.js"]
}
}Testing Components
The core pattern: render a component, query elements, assert on their state.
// components/LoginForm.js
import React, { useState } from 'react';
import { View, TextInput, TouchableOpacity, Text } from 'react-native';
export function LoginForm({ onSubmit }) {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
return (
<View>
<TextInput
testID="email-input"
value={email}
onChangeText={setEmail}
placeholder="Email"
/>
<TextInput
testID="password-input"
value={password}
onChangeText={setPassword}
secureTextEntry
placeholder="Password"
/>
<TouchableOpacity
testID="submit-button"
onPress={() => onSubmit({ email, password })}
disabled={!email || !password}
>
<Text>Log In</Text>
</TouchableOpacity>
</View>
);
}// components/LoginForm.test.js
import React from 'react';
import { render, fireEvent, screen } from '@testing-library/react-native';
import { LoginForm } from './LoginForm';
describe('LoginForm', () => {
it('renders email and password inputs', () => {
render(<LoginForm onSubmit={jest.fn()} />);
expect(screen.getByTestId('email-input')).toBeOnTheScreen();
expect(screen.getByTestId('password-input')).toBeOnTheScreen();
});
it('calls onSubmit with email and password when form is submitted', () => {
const mockSubmit = jest.fn();
render(<LoginForm onSubmit={mockSubmit} />);
fireEvent.changeText(screen.getByTestId('email-input'), 'user@example.com');
fireEvent.changeText(screen.getByTestId('password-input'), 'secret123');
fireEvent.press(screen.getByTestId('submit-button'));
expect(mockSubmit).toHaveBeenCalledWith({
email: 'user@example.com',
password: 'secret123',
});
});
it('does not call onSubmit when fields are empty', () => {
const mockSubmit = jest.fn();
render(<LoginForm onSubmit={mockSubmit} />);
fireEvent.press(screen.getByTestId('submit-button'));
expect(mockSubmit).not.toHaveBeenCalled();
});
});Querying Elements
React Native Testing Library provides several query methods:
// By testID (most reliable, doesn't depend on text content)
screen.getByTestId('submit-button')
// By text (good for visible labels)
screen.getByText('Log In')
// By role (accessibility-aware)
screen.getByRole('button', { name: 'Log In' })
// By placeholder text
screen.getByPlaceholderText('Email')
// Returns null instead of throwing if not found
screen.queryByTestId('optional-element')
// Async variants — wait for element to appear
await screen.findByTestId('success-message')Prefer getByTestId for reliability. Text-based selectors break when copy changes; testIDs are stable.
Testing Async Components
Many components make network requests or perform async operations. Use waitFor or findBy* queries:
// components/UserProfile.js
import React, { useEffect, useState } from 'react';
import { View, Text, ActivityIndicator } from 'react-native';
import { fetchUser } from '../api/users';
export function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchUser(userId).then((data) => {
setUser(data);
setLoading(false);
});
}, [userId]);
if (loading) return <ActivityIndicator testID="loader" />;
return (
<View>
<Text testID="user-name">{user.name}</Text>
<Text testID="user-email">{user.email}</Text>
</View>
);
}// components/UserProfile.test.js
import React from 'react';
import { render, screen, waitFor } from '@testing-library/react-native';
import { UserProfile } from './UserProfile';
import { fetchUser } from '../api/users';
// Mock the API module
jest.mock('../api/users');
describe('UserProfile', () => {
it('shows loader while fetching', () => {
fetchUser.mockReturnValue(new Promise(() => {})); // never resolves
render(<UserProfile userId="123" />);
expect(screen.getByTestId('loader')).toBeOnTheScreen();
});
it('shows user data after loading', async () => {
fetchUser.mockResolvedValue({ name: 'Alice', email: 'alice@example.com' });
render(<UserProfile userId="123" />);
await waitFor(() => {
expect(screen.getByTestId('user-name')).toHaveTextContent('Alice');
expect(screen.getByTestId('user-email')).toHaveTextContent('alice@example.com');
});
});
it('passes userId to the API', async () => {
fetchUser.mockResolvedValue({ name: 'Bob', email: 'bob@example.com' });
render(<UserProfile userId="456" />);
await waitFor(() => screen.getByTestId('user-name'));
expect(fetchUser).toHaveBeenCalledWith('456');
});
});Testing Custom Hooks
Use renderHook from React Native Testing Library to test hooks in isolation:
// hooks/useCounter.js
import { useState, useCallback } from 'react';
export function useCounter(initialValue = 0) {
const [count, setCount] = useState(initialValue);
const increment = useCallback(() => setCount((c) => c + 1), []);
const decrement = useCallback(() => setCount((c) => c - 1), []);
const reset = useCallback(() => setCount(initialValue), [initialValue]);
return { count, increment, decrement, reset };
}// hooks/useCounter.test.js
import { renderHook, act } from '@testing-library/react-native';
import { useCounter } from './useCounter';
describe('useCounter', () => {
it('initializes with default value of 0', () => {
const { result } = renderHook(() => useCounter());
expect(result.current.count).toBe(0);
});
it('initializes with provided value', () => {
const { result } = renderHook(() => useCounter(10));
expect(result.current.count).toBe(10);
});
it('increments count', () => {
const { result } = renderHook(() => useCounter());
act(() => result.current.increment());
expect(result.current.count).toBe(1);
});
it('decrements count', () => {
const { result } = renderHook(() => useCounter(5));
act(() => result.current.decrement());
expect(result.current.count).toBe(4);
});
it('resets to initial value', () => {
const { result } = renderHook(() => useCounter(3));
act(() => result.current.increment());
act(() => result.current.increment());
act(() => result.current.reset());
expect(result.current.count).toBe(3);
});
});Testing Hooks with Async Operations
// hooks/useFetchUser.js
import { useState, useEffect } from 'react';
import { fetchUser } from '../api/users';
export function useFetchUser(userId) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
setLoading(true);
fetchUser(userId)
.then(setUser)
.catch(setError)
.finally(() => setLoading(false));
}, [userId]);
return { user, loading, error };
}// hooks/useFetchUser.test.js
import { renderHook, waitFor } from '@testing-library/react-native';
import { useFetchUser } from './useFetchUser';
import { fetchUser } from '../api/users';
jest.mock('../api/users');
describe('useFetchUser', () => {
it('starts in loading state', () => {
fetchUser.mockReturnValue(new Promise(() => {}));
const { result } = renderHook(() => useFetchUser('123'));
expect(result.current.loading).toBe(true);
expect(result.current.user).toBeNull();
});
it('sets user on success', async () => {
const mockUser = { id: '123', name: 'Alice' };
fetchUser.mockResolvedValue(mockUser);
const { result } = renderHook(() => useFetchUser('123'));
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.user).toEqual(mockUser);
expect(result.current.error).toBeNull();
});
it('sets error on failure', async () => {
const error = new Error('Network error');
fetchUser.mockRejectedValue(error);
const { result } = renderHook(() => useFetchUser('123'));
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.user).toBeNull();
expect(result.current.error).toEqual(error);
});
});Testing Navigation
React Navigation is notoriously difficult to test because it relies on a context provider. The cleanest approach: wrap your test renders in a navigation container.
// test-utils/navigation.js
import React from 'react';
import { NavigationContainer } from '@react-navigation/native';
export function NavigationWrapper({ children }) {
return <NavigationContainer>{children}</NavigationContainer>;
}For components that use useNavigation() internally, you need either the wrapper or to mock the hook:
// Mock navigation approach
jest.mock('@react-navigation/native', () => ({
...jest.requireActual('@react-navigation/native'),
useNavigation: () => ({
navigate: jest.fn(),
goBack: jest.fn(),
push: jest.fn(),
}),
}));
// Then in tests:
import { useNavigation } from '@react-navigation/native';
it('navigates to details on item press', () => {
const { navigate } = useNavigation();
render(<ItemList />);
fireEvent.press(screen.getByTestId('item-0'));
expect(navigate).toHaveBeenCalledWith('ItemDetails', { id: '0' });
});Snapshot Testing
Snapshot tests capture component output and alert you to unintended visual changes:
it('matches snapshot', () => {
const tree = render(<LoginForm onSubmit={jest.fn()} />).toJSON();
expect(tree).toMatchSnapshot();
});Use snapshots sparingly. They're good for catching accidental changes to stable components but become a maintenance burden on components that change frequently. When a snapshot fails, verify the change was intentional before updating with jest --updateSnapshot.
Running Tests
# Run all tests
npm <span class="hljs-built_in">test
<span class="hljs-comment"># Watch mode
npm <span class="hljs-built_in">test -- --watch
<span class="hljs-comment"># Run specific file
npm <span class="hljs-built_in">test -- LoginForm.test.js
<span class="hljs-comment"># With coverage
npm <span class="hljs-built_in">test -- --coverageCoverage reports show which lines and branches are untested:
npm test -- --coverage --coverageDirectory=coverage
open coverage/lcov-report/index.htmlWhat to Test and What Not To
Good candidates for unit tests:
- Business logic in hooks and utility functions
- Conditional rendering based on props or state
- Error states and loading states
- Form validation and submission
- Navigation calls
Less valuable to unit test:
- Component styling (use visual tests or screenshots)
- Native module behavior (mock it, test the mock behavior)
- Third-party library internals
The goal is tests that catch real bugs. A test that only checks "this component renders without crashing" adds little value. A test that verifies "the submit button is disabled when the email field is empty" catches a real regression.