Introduction to React Native Testing
React Native Testing Library (RNTL) is the industry standard for testing React Native applications. Built on the principles of testing user behavior rather than implementation details, RNTL encourages writing maintainable tests that give you confidence in your application’s functionality.
This guide covers comprehensive testing strategies, from basic component tests to complex integration scenarios, native module mocking, and performance testing best practices.
Why React Native Testing Library?
RNTL follows the guiding principle: “The more your tests resemble the way your software is used, the more confidence they can give you.”
Key Advantages
- User-Centric Testing: Test components as users interact with them
- Implementation Independence: Refactor components without breaking tests
- Async Utilities: Built-in helpers for asynchronous operations
- Accessibility Focus: Encourages accessible component design
- Jest Integration: Seamless integration with Jest ecosystem
Comparison with Other Testing Approaches
Approach | Focus | Maintenance | User Confidence |
---|---|---|---|
Enzyme (deprecated) | Implementation details | High | Low |
React Native Testing Library | User behavior | Low | High |
Detox (E2E) | Full app flows | Medium | Very High |
Environment Setup
Installation
npm install --save-dev @testing-library/react-native
npm install --save-dev @testing-library/jest-native
npm install --save-dev jest @types/jest
Jest Configuration
Configure jest.config.js
:
module.exports = {
preset: 'react-native' (as discussed in [Detox: Grey-Box Testing for React Native Applications](/blog/detox-react-native-grey-box)),
setupFilesAfterEnv: [
'@testing-library/jest-native/extend-expect',
'<rootDir>/jest.setup.js'
],
transformIgnorePatterns: [
'node_modules/(?!(react-native|@react-native|@react-navigation|react-native-.*)/)'
],
collectCoverageFrom: [
'src/**/*.{js,jsx,ts,tsx}',
'!src/**/*.test.{js,jsx,ts,tsx}',
'!src/**/__tests__/**'
],
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1'
}
};
Create jest.setup.js
:
import 'react-native-gesture-handler/jestSetup';
jest.mock('react-native/Libraries/Animated/NativeAnimatedHelper');
global.fetch = jest.fn();
beforeEach(() => {
jest (as discussed in [API Testing Architecture: From Monoliths to Microservices](/blog/api-testing-architecture-microservices)).clearAllMocks();
});
Component Testing Fundamentals
Basic Component Test
// src/components/Button.tsx
import React from 'react';
import { TouchableOpacity, Text, StyleSheet } from 'react-native';
interface ButtonProps {
onPress: () => void;
title: string;
disabled?: boolean;
}
export const Button: React.FC<ButtonProps> = ({
onPress,
title,
disabled = false
}) => (
<TouchableOpacity
testID="custom-button"
onPress={onPress}
disabled={disabled}
style={[styles.button, disabled && styles.disabled]}
>
<Text style={styles.text}>{title}</Text>
</TouchableOpacity>
);
const styles = StyleSheet.create({
button: {
backgroundColor: '#007AFF',
padding: 12,
borderRadius: 8,
},
disabled: {
backgroundColor: '#CCC',
},
text: {
color: 'white',
fontSize: 16,
},
});
// src/components/__tests__/Button.test.tsx
import React from 'react';
import { render, fireEvent } from '@testing-library/react-native';
import { Button } from '../Button';
describe('Button Component', () => {
it('renders correctly with title', () => {
const { getByText } = render(
<Button onPress={() => {}} title="Click Me" />
);
expect(getByText('Click Me')).toBeTruthy();
});
it('calls onPress when pressed', () => {
const onPressMock = jest (as discussed in [GraphQL Testing: Complete Guide with Examples](/blog/graphql-testing-guide)).fn();
const { getByTestId } = render(
<Button onPress={onPressMock} title="Press" />
);
fireEvent.press(getByTestId('custom-button'));
expect(onPressMock).toHaveBeenCalledTimes(1);
});
it('does not call onPress when disabled', () => {
const onPressMock = jest.fn();
const { getByTestId } = render(
<Button onPress={onPressMock} title="Press" disabled />
);
fireEvent.press(getByTestId('custom-button'));
expect(onPressMock).not.toHaveBeenCalled();
});
});
Testing Forms and Input
// src/components/LoginForm.tsx
import React, { useState } from 'react';
import { View, TextInput, Text } from 'react-native';
import { Button } from './Button';
interface LoginFormProps {
onSubmit: (email: string, password: string) => void;
}
export const LoginForm: React.FC<LoginFormProps> = ({ onSubmit }) => {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const handleSubmit = () => {
if (!email || !password) {
setError('Email and password are required');
return;
}
if (!email.includes('@')) {
setError('Invalid email format');
return;
}
setError('');
onSubmit(email, password);
};
return (
<View testID="login-form">
<TextInput
testID="email-input"
placeholder="Email"
value={email}
onChangeText={setEmail}
autoCapitalize="none"
keyboardType="email-address"
/>
<TextInput
testID="password-input"
placeholder="Password"
value={password}
onChangeText={setPassword}
secureTextEntry
/>
{error ? <Text testID="error-message">{error}</Text> : null}
<Button onPress={handleSubmit} title="Login" />
</View>
);
};
// src/components/__tests__/LoginForm.test.tsx
import React from 'react';
import { render, fireEvent } from '@testing-library/react-native';
import { LoginForm } from '../LoginForm';
describe('LoginForm', () => {
it('submits form with valid credentials', () => {
const onSubmitMock = jest.fn();
const { getByTestId } = render(
<LoginForm onSubmit={onSubmitMock} />
);
fireEvent.changeText(
getByTestId('email-input'),
'user@example.com'
);
fireEvent.changeText(
getByTestId('password-input'),
'password123'
);
fireEvent.press(getByTestId('custom-button'));
expect(onSubmitMock).toHaveBeenCalledWith(
'user@example.com',
'password123'
);
});
it('shows error for empty fields', () => {
const { getByTestId, getByText } = render(
<LoginForm onSubmit={() => {}} />
);
fireEvent.press(getByTestId('custom-button'));
expect(
getByText('Email and password are required')
).toBeTruthy();
});
it('shows error for invalid email format', () => {
const { getByTestId, getByText } = render(
<LoginForm onSubmit={() => {}} />
);
fireEvent.changeText(getByTestId('email-input'), 'invalid');
fireEvent.changeText(getByTestId('password-input'), 'pass');
fireEvent.press(getByTestId('custom-button'));
expect(getByText('Invalid email format')).toBeTruthy();
});
});
Asynchronous Testing
Testing API Calls
// src/services/api.ts
export const fetchUserProfile = async (userId: string) => {
const response = await fetch(
`https://api.example.com/users/${userId}`
);
if (!response.ok) {
throw new Error('Failed to fetch user');
}
return response.json();
};
// src/components/UserProfile.tsx
import React, { useEffect, useState } from 'react';
import { View, Text, ActivityIndicator } from 'react-native';
import { fetchUserProfile } from '../services/api';
interface User {
id: string;
name: string;
email: string;
}
export const UserProfile: React.FC<{ userId: string }> = ({
userId
}) => {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
useEffect(() => {
const loadUser = async () => {
try {
const data = await fetchUserProfile(userId);
setUser(data);
} catch (err) {
setError('Failed to load user');
} finally {
setLoading(false);
}
};
loadUser();
}, [userId]);
if (loading) {
return <ActivityIndicator testID="loading" />;
}
if (error) {
return <Text testID="error">{error}</Text>;
}
return (
<View testID="user-profile">
<Text testID="user-name">{user?.name}</Text>
<Text testID="user-email">{user?.email}</Text>
</View>
);
};
// src/components/__tests__/UserProfile.test.tsx
import React from 'react';
import {
render,
waitFor,
screen
} from '@testing-library/react-native';
import { UserProfile } from '../UserProfile';
import * as api from '../../services/api';
jest.mock('../../services/api');
describe('UserProfile', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('displays loading state initially', () => {
(api.fetchUserProfile as jest.Mock).mockImplementation(
() => new Promise(() => {}) // Never resolves
);
const { getByTestId } = render(
<UserProfile userId="123" />
);
expect(getByTestId('loading')).toBeTruthy();
});
it('displays user data after successful fetch', async () => {
const mockUser = {
id: '123',
name: 'John Doe',
email: 'john@example.com'
};
(api.fetchUserProfile as jest.Mock).mockResolvedValue(
mockUser
);
const { getByTestId } = render(
<UserProfile userId="123" />
);
await waitFor(() => {
expect(getByTestId('user-name')).toHaveTextContent(
'John Doe'
);
});
expect(getByTestId('user-email')).toHaveTextContent(
'john@example.com'
);
});
it('displays error message on fetch failure', async () => {
(api.fetchUserProfile as jest.Mock).mockRejectedValue(
new Error('Network error')
);
const { getByTestId } = render(
<UserProfile userId="123" />
);
await waitFor(() => {
expect(getByTestId('error')).toHaveTextContent(
'Failed to load user'
);
});
});
});
Testing Custom Hooks
// src/hooks/useCounter.ts
import { useState, useCallback } from 'react';
export const 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 };
};
// src/hooks/__tests__/useCounter.test.ts
import { renderHook, act } from '@testing-library/react-native';
import { useCounter } from '../useCounter';
describe('useCounter', () => {
it('initializes with default value', () => {
const { result } = renderHook(() => useCounter());
expect(result.current.count).toBe(0);
});
it('initializes with custom 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(10));
act(() => {
result.current.increment();
result.current.increment();
});
expect(result.current.count).toBe(12);
act(() => {
result.current.reset();
});
expect(result.current.count).toBe(10);
});
});
Mocking Native Modules
Mocking React Native APIs
// jest.setup.js additions
jest.mock('react-native/Libraries/Alert/Alert', () => ({
alert: jest.fn(),
}));
jest.mock('@react-native-async-storage/async-storage', () => ({
setItem: jest.fn(() => Promise.resolve()),
getItem: jest.fn(() => Promise.resolve(null)),
removeItem: jest.fn(() => Promise.resolve()),
clear: jest.fn(() => Promise.resolve()),
}));
// Example test using mocked AsyncStorage
import AsyncStorage from '@react-native-async-storage/async-storage';
import { saveUserToken } from '../storage';
it('saves token to AsyncStorage', async () => {
await saveUserToken('abc123');
expect(AsyncStorage.setItem).toHaveBeenCalledWith(
'userToken',
'abc123'
);
});
Mocking Navigation
// __mocks__/@react-navigation/native.ts
export const useNavigation = () => ({
navigate: jest.fn(),
goBack: jest.fn(),
setOptions: jest.fn(),
});
export const useRoute = () => ({
params: {},
});
// Component test using navigation
import { useNavigation } from '@react-navigation/native';
import { render, fireEvent } from '@testing-library/react-native';
import { ProductDetails } from '../ProductDetails';
jest.mock('@react-navigation/native');
it('navigates to cart on add to cart', () => {
const navigate = jest.fn();
(useNavigation as jest.Mock).mockReturnValue({ navigate });
const { getByText } = render(
<ProductDetails productId="123" />
);
fireEvent.press(getByText('Add to Cart'));
expect(navigate).toHaveBeenCalledWith('Cart');
});
Snapshot Testing
Component Snapshots
import React from 'react';
import { render } from '@testing-library/react-native';
import { ProductCard } from '../ProductCard';
it('matches snapshot', () => {
const product = {
id: '1',
name: 'Product Name',
price: 29.99,
image: 'https://example.com/image.jpg'
};
const { toJSON } = render(<ProductCard product={product} />);
expect(toJSON()).toMatchSnapshot();
});
Inline Snapshots
it('renders price correctly', () => {
const { getByTestId } = render(
<ProductPrice price={29.99} currency="USD" />
);
expect(getByTestId('price').props.children)
.toMatchInlineSnapshot(`"$29.99"`);
});
Advanced Testing Patterns
Testing Context Providers
// src/contexts/AuthContext.tsx
import React, { createContext, useState, useContext } from 'react';
interface AuthContextType {
user: User | null;
login: (email: string, password: string) => Promise<void>;
logout: () => void;
}
const AuthContext = createContext<AuthContextType | undefined>(
undefined
);
export const AuthProvider: React.FC = ({ children }) => {
const [user, setUser] = useState<User | null>(null);
const login = async (email: string, password: string) => {
// API call logic
const userData = await apiLogin(email, password);
setUser(userData);
};
const logout = () => setUser(null);
return (
<AuthContext.Provider value={{ user, login, logout }}>
{children}
</AuthContext.Provider>
);
};
export const useAuth = () => {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within AuthProvider');
}
return context;
};
// Test helper
const renderWithAuth = (component: React.ReactElement) => {
return render(<AuthProvider>{component}</AuthProvider>);
};
// Component test
it('displays user name after login', async () => {
const { getByTestId } = renderWithAuth(<Dashboard />);
await waitFor(() => {
expect(getByTestId('welcome-message')).toHaveTextContent(
'Welcome, John'
);
});
});
Testing Lists and FlatLists
import React from 'react';
import { FlatList, Text } from 'react-native';
interface Item {
id: string;
title: string;
}
const ItemList: React.FC<{ items: Item[] }> = ({ items }) => (
<FlatList
testID="item-list"
data={items}
keyExtractor={item => item.id}
renderItem={({ item }) => (
<Text testID={`item-${item.id}`}>{item.title}</Text>
)}
/>
);
it('renders all items in list', () => {
const items = [
{ id: '1', title: 'First' },
{ id: '2', title: 'Second' },
{ id: '3', title: 'Third' },
];
const { getByTestId } = render(<ItemList items={items} />);
expect(getByTestId('item-1')).toHaveTextContent('First');
expect(getByTestId('item-2')).toHaveTextContent('Second');
expect(getByTestId('item-3')).toHaveTextContent('Third');
});
Best Practices
Query Priority
Follow this priority when selecting elements:
- getByRole / getByLabelText: Accessible to all users
- getByPlaceholderText: For form elements
- getByText: For non-interactive elements
- getByTestId: Last resort for implementation details
Test Organization
describe('Component Name', () => {
describe('rendering', () => {
it('renders initial state correctly', () => {});
it('renders loading state', () => {});
});
describe('user interactions', () => {
it('handles button click', () => {});
it('submits form on enter', () => {});
});
describe('edge cases', () => {
it('handles network errors gracefully', () => {});
it('validates input boundaries', () => {});
});
});
Coverage Goals
Metric | Target | Priority |
---|---|---|
Statements | 80%+ | High |
Branches | 75%+ | High |
Functions | 80%+ | Medium |
Lines | 80%+ | Medium |
Continuous Integration
GitHub Actions Example
name: React Native Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '18'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm test -- --coverage --watchAll=false
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v3
with:
files: ./coverage/lcov.info
Conclusion
React Native Testing Library provides a robust foundation for testing mobile applications with a user-centric approach. By focusing on how users interact with your app rather than implementation details, you build a test suite that’s resilient to refactoring and provides genuine confidence in your application’s behavior.
Key Takeaways:
- Prioritize accessibility-based queries (role, label) over testID
- Use
waitFor
for async operations and state updates - Mock native modules and external dependencies appropriately
- Maintain high test coverage while avoiding over-testing
- Integrate testing into CI/CD pipelines for continuous quality assurance
Start by testing critical user flows and gradually expand coverage to achieve comprehensive test protection.