Module Overview
Estimated Time: 4 hours | Difficulty: Intermediate | Prerequisites: Core React Native concepts
- Jest configuration for React Native
- Testing components with RNTL
- Mocking native modules
- Testing hooks and context
- Snapshot testing
- Code coverage
Jest Setup
Default Configuration
React Native and Expo projects come with Jest pre-configured:Copy
// package.json
{
"scripts": {
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage"
},
"jest": {
"preset": "jest-expo"
}
}
Custom Jest Configuration
Copy
// jest.config.js
module.exports = {
preset: 'jest-expo', // or 'react-native'
// Setup files
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
// Module resolution
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1',
},
// Transform
transformIgnorePatterns: [
'node_modules/(?!((jest-)?react-native|@react-native(-community)?)|expo(nent)?|@expo(nent)?/.*|@expo-google-fonts/.*|react-navigation|@react-navigation/.*|@unimodules/.*|unimodules|sentry-expo|native-base|react-native-svg)',
],
// Coverage
collectCoverageFrom: [
'src/**/*.{ts,tsx}',
'!src/**/*.d.ts',
'!src/**/*.stories.{ts,tsx}',
'!src/**/index.{ts,tsx}',
],
coverageThreshold: {
global: {
branches: 70,
functions: 70,
lines: 70,
statements: 70,
},
},
// Test environment
testEnvironment: 'jsdom',
// Test patterns
testMatch: ['**/__tests__/**/*.test.{ts,tsx}', '**/*.test.{ts,tsx}'],
// Module file extensions
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
// Clear mocks between tests
clearMocks: true,
// Verbose output
verbose: true,
};
Setup File
Copy
// jest.setup.js
import '@testing-library/jest-native/extend-expect';
// Mock react-native-reanimated
jest.mock('react-native-reanimated', () => {
const Reanimated = require('react-native-reanimated/mock');
Reanimated.default.call = () => {};
return Reanimated;
});
// Mock expo-font
jest.mock('expo-font', () => ({
loadAsync: jest.fn(),
isLoaded: jest.fn(() => true),
}));
// Mock AsyncStorage
jest.mock('@react-native-async-storage/async-storage', () =>
require('@react-native-async-storage/async-storage/jest/async-storage-mock')
);
// Mock navigation
jest.mock('@react-navigation/native', () => {
const actualNav = jest.requireActual('@react-navigation/native');
return {
...actualNav,
useNavigation: () => ({
navigate: jest.fn(),
goBack: jest.fn(),
setOptions: jest.fn(),
}),
useRoute: () => ({
params: {},
}),
useFocusEffect: jest.fn(),
};
});
// Silence console warnings in tests
global.console = {
...console,
warn: jest.fn(),
error: jest.fn(),
};
// Mock timers
jest.useFakeTimers();
React Native Testing Library
Install RNTL:Copy
npm install --save-dev @testing-library/react-native @testing-library/jest-native
Basic Component Testing
Copy
// components/Button.tsx
import { Pressable, Text, StyleSheet } from 'react-native';
interface ButtonProps {
title: string;
onPress: () => void;
disabled?: boolean;
testID?: string;
}
export function Button({ title, onPress, disabled, testID }: ButtonProps) {
return (
<Pressable
style={[styles.button, disabled && styles.disabled]}
onPress={onPress}
disabled={disabled}
testID={testID}
accessibilityRole="button"
accessibilityState={{ disabled }}
>
<Text style={styles.text}>{title}</Text>
</Pressable>
);
}
const styles = StyleSheet.create({
button: {
backgroundColor: '#3b82f6',
padding: 12,
borderRadius: 8,
alignItems: 'center',
},
disabled: {
opacity: 0.5,
},
text: {
color: '#fff',
fontWeight: '600',
},
});
Copy
// components/__tests__/Button.test.tsx
import { render, screen, fireEvent } from '@testing-library/react-native';
import { Button } from '../Button';
describe('Button', () => {
it('renders correctly with title', () => {
render(<Button title="Press Me" onPress={() => {}} />);
expect(screen.getByText('Press Me')).toBeTruthy();
});
it('calls onPress when pressed', () => {
const onPress = jest.fn();
render(<Button title="Press Me" onPress={onPress} />);
fireEvent.press(screen.getByText('Press Me'));
expect(onPress).toHaveBeenCalledTimes(1);
});
it('does not call onPress when disabled', () => {
const onPress = jest.fn();
render(<Button title="Press Me" onPress={onPress} disabled />);
fireEvent.press(screen.getByText('Press Me'));
expect(onPress).not.toHaveBeenCalled();
});
it('has correct accessibility props', () => {
render(<Button title="Submit" onPress={() => {}} />);
const button = screen.getByRole('button');
expect(button).toBeTruthy();
});
it('shows disabled state in accessibility', () => {
render(<Button title="Submit" onPress={() => {}} disabled />);
const button = screen.getByRole('button');
expect(button).toBeDisabled();
});
});
Testing with User Events
Copy
// components/__tests__/LoginForm.test.tsx
import { render, screen, fireEvent, waitFor } from '@testing-library/react-native';
import { LoginForm } from '../LoginForm';
describe('LoginForm', () => {
const mockOnSubmit = jest.fn();
beforeEach(() => {
mockOnSubmit.mockClear();
});
it('renders all form fields', () => {
render(<LoginForm onSubmit={mockOnSubmit} />);
expect(screen.getByPlaceholderText('Email')).toBeTruthy();
expect(screen.getByPlaceholderText('Password')).toBeTruthy();
expect(screen.getByText('Login')).toBeTruthy();
});
it('updates input values on change', () => {
render(<LoginForm onSubmit={mockOnSubmit} />);
const emailInput = screen.getByPlaceholderText('Email');
const passwordInput = screen.getByPlaceholderText('Password');
fireEvent.changeText(emailInput, 'test@example.com');
fireEvent.changeText(passwordInput, 'password123');
expect(emailInput.props.value).toBe('test@example.com');
expect(passwordInput.props.value).toBe('password123');
});
it('submits form with correct values', async () => {
render(<LoginForm onSubmit={mockOnSubmit} />);
fireEvent.changeText(screen.getByPlaceholderText('Email'), 'test@example.com');
fireEvent.changeText(screen.getByPlaceholderText('Password'), 'password123');
fireEvent.press(screen.getByText('Login'));
await waitFor(() => {
expect(mockOnSubmit).toHaveBeenCalledWith({
email: 'test@example.com',
password: 'password123',
});
});
});
it('shows validation errors for empty fields', async () => {
render(<LoginForm onSubmit={mockOnSubmit} />);
fireEvent.press(screen.getByText('Login'));
await waitFor(() => {
expect(screen.getByText('Email is required')).toBeTruthy();
expect(screen.getByText('Password is required')).toBeTruthy();
});
expect(mockOnSubmit).not.toHaveBeenCalled();
});
it('shows error for invalid email', async () => {
render(<LoginForm onSubmit={mockOnSubmit} />);
fireEvent.changeText(screen.getByPlaceholderText('Email'), 'invalid-email');
fireEvent.changeText(screen.getByPlaceholderText('Password'), 'password123');
fireEvent.press(screen.getByText('Login'));
await waitFor(() => {
expect(screen.getByText('Invalid email address')).toBeTruthy();
});
});
it('shows loading state during submission', async () => {
mockOnSubmit.mockImplementation(() => new Promise(resolve => setTimeout(resolve, 1000)));
render(<LoginForm onSubmit={mockOnSubmit} />);
fireEvent.changeText(screen.getByPlaceholderText('Email'), 'test@example.com');
fireEvent.changeText(screen.getByPlaceholderText('Password'), 'password123');
fireEvent.press(screen.getByText('Login'));
expect(screen.getByTestId('loading-indicator')).toBeTruthy();
await waitFor(() => {
expect(screen.queryByTestId('loading-indicator')).toBeNull();
});
});
});
Testing Hooks
Custom Hook Testing
Copy
// hooks/useCounter.ts
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 };
}
Copy
// 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();
result.current.reset();
});
expect(result.current.count).toBe(10);
});
});
Testing Async Hooks
Copy
// hooks/useApi.ts
import { useState, useEffect } from 'react';
interface UseApiResult<T> {
data: T | null;
loading: boolean;
error: Error | null;
refetch: () => void;
}
export function useApi<T>(url: string): UseApiResult<T> {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
const fetchData = async () => {
setLoading(true);
setError(null);
try {
const response = await fetch(url);
if (!response.ok) throw new Error('Failed to fetch');
const json = await response.json();
setData(json);
} catch (e) {
setError(e as Error);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchData();
}, [url]);
return { data, loading, error, refetch: fetchData };
}
Copy
// hooks/__tests__/useApi.test.ts
import { renderHook, waitFor } from '@testing-library/react-native';
import { useApi } from '../useApi';
// Mock fetch
global.fetch = jest.fn();
describe('useApi', () => {
beforeEach(() => {
(fetch as jest.Mock).mockClear();
});
it('fetches data successfully', async () => {
const mockData = { id: 1, name: 'Test' };
(fetch as jest.Mock).mockResolvedValueOnce({
ok: true,
json: async () => mockData,
});
const { result } = renderHook(() => useApi('/api/test'));
expect(result.current.loading).toBe(true);
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.data).toEqual(mockData);
expect(result.current.error).toBeNull();
});
it('handles fetch error', async () => {
(fetch as jest.Mock).mockResolvedValueOnce({
ok: false,
});
const { result } = renderHook(() => useApi('/api/test'));
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.data).toBeNull();
expect(result.current.error).toBeInstanceOf(Error);
});
it('handles network error', async () => {
(fetch as jest.Mock).mockRejectedValueOnce(new Error('Network error'));
const { result } = renderHook(() => useApi('/api/test'));
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.error?.message).toBe('Network error');
});
it('refetches data', async () => {
const mockData1 = { id: 1 };
const mockData2 = { id: 2 };
(fetch as jest.Mock)
.mockResolvedValueOnce({ ok: true, json: async () => mockData1 })
.mockResolvedValueOnce({ ok: true, json: async () => mockData2 });
const { result } = renderHook(() => useApi('/api/test'));
await waitFor(() => {
expect(result.current.data).toEqual(mockData1);
});
result.current.refetch();
await waitFor(() => {
expect(result.current.data).toEqual(mockData2);
});
expect(fetch).toHaveBeenCalledTimes(2);
});
});
Testing Context
Copy
// context/AuthContext.tsx
import { createContext, useContext, useState, ReactNode } from 'react';
interface User {
id: string;
email: string;
}
interface AuthContextType {
user: User | null;
login: (email: string, password: string) => Promise<void>;
logout: () => void;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const login = async (email: string, password: string) => {
// Simulate API call
const response = await fetch('/api/login', {
method: 'POST',
body: JSON.stringify({ email, password }),
});
const data = await response.json();
setUser(data.user);
};
const logout = () => {
setUser(null);
};
return (
<AuthContext.Provider value={{ user, login, logout }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within AuthProvider');
}
return context;
}
Copy
// context/__tests__/AuthContext.test.tsx
import { renderHook, act, waitFor } from '@testing-library/react-native';
import { AuthProvider, useAuth } from '../AuthContext';
global.fetch = jest.fn();
describe('AuthContext', () => {
const wrapper = ({ children }: { children: React.ReactNode }) => (
<AuthProvider>{children}</AuthProvider>
);
beforeEach(() => {
(fetch as jest.Mock).mockClear();
});
it('provides initial null user', () => {
const { result } = renderHook(() => useAuth(), { wrapper });
expect(result.current.user).toBeNull();
});
it('logs in user successfully', async () => {
const mockUser = { id: '1', email: 'test@example.com' };
(fetch as jest.Mock).mockResolvedValueOnce({
json: async () => ({ user: mockUser }),
});
const { result } = renderHook(() => useAuth(), { wrapper });
await act(async () => {
await result.current.login('test@example.com', 'password');
});
expect(result.current.user).toEqual(mockUser);
});
it('logs out user', async () => {
const mockUser = { id: '1', email: 'test@example.com' };
(fetch as jest.Mock).mockResolvedValueOnce({
json: async () => ({ user: mockUser }),
});
const { result } = renderHook(() => useAuth(), { wrapper });
await act(async () => {
await result.current.login('test@example.com', 'password');
});
expect(result.current.user).not.toBeNull();
act(() => {
result.current.logout();
});
expect(result.current.user).toBeNull();
});
it('throws error when used outside provider', () => {
expect(() => {
renderHook(() => useAuth());
}).toThrow('useAuth must be used within AuthProvider');
});
});
Mocking Native Modules
Common Mocks
Copy
// __mocks__/react-native-camera.ts
export const RNCamera = {
Constants: {
Type: { back: 'back', front: 'front' },
FlashMode: { on: 'on', off: 'off', auto: 'auto' },
},
};
export default {
takePictureAsync: jest.fn().mockResolvedValue({ uri: 'mock-uri' }),
};
Copy
// __mocks__/@react-native-async-storage/async-storage.ts
let store: Record<string, string> = {};
export default {
setItem: jest.fn((key: string, value: string) => {
store[key] = value;
return Promise.resolve();
}),
getItem: jest.fn((key: string) => {
return Promise.resolve(store[key] || null);
}),
removeItem: jest.fn((key: string) => {
delete store[key];
return Promise.resolve();
}),
clear: jest.fn(() => {
store = {};
return Promise.resolve();
}),
getAllKeys: jest.fn(() => {
return Promise.resolve(Object.keys(store));
}),
multiGet: jest.fn((keys: string[]) => {
return Promise.resolve(keys.map(key => [key, store[key] || null]));
}),
multiSet: jest.fn((pairs: [string, string][]) => {
pairs.forEach(([key, value]) => {
store[key] = value;
});
return Promise.resolve();
}),
};
Copy
// __mocks__/expo-location.ts
export const requestForegroundPermissionsAsync = jest.fn().mockResolvedValue({
status: 'granted',
});
export const getCurrentPositionAsync = jest.fn().mockResolvedValue({
coords: {
latitude: 37.7749,
longitude: -122.4194,
altitude: 0,
accuracy: 5,
heading: 0,
speed: 0,
},
timestamp: Date.now(),
});
export const watchPositionAsync = jest.fn().mockReturnValue({
remove: jest.fn(),
});
Testing with Mocked Modules
Copy
// screens/__tests__/LocationScreen.test.tsx
import { render, screen, waitFor } from '@testing-library/react-native';
import * as Location from 'expo-location';
import { LocationScreen } from '../LocationScreen';
jest.mock('expo-location');
describe('LocationScreen', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('requests location permission on mount', async () => {
render(<LocationScreen />);
await waitFor(() => {
expect(Location.requestForegroundPermissionsAsync).toHaveBeenCalled();
});
});
it('displays location when permission granted', async () => {
render(<LocationScreen />);
await waitFor(() => {
expect(screen.getByText(/37.7749/)).toBeTruthy();
expect(screen.getByText(/-122.4194/)).toBeTruthy();
});
});
it('shows error when permission denied', async () => {
(Location.requestForegroundPermissionsAsync as jest.Mock).mockResolvedValueOnce({
status: 'denied',
});
render(<LocationScreen />);
await waitFor(() => {
expect(screen.getByText('Location permission denied')).toBeTruthy();
});
});
});
Snapshot Testing
Copy
// components/__tests__/ProductCard.test.tsx
import { render } from '@testing-library/react-native';
import { ProductCard } from '../ProductCard';
describe('ProductCard', () => {
const defaultProps = {
id: '1',
name: 'Test Product',
price: 99.99,
image: 'https://example.com/image.jpg',
onPress: jest.fn(),
};
it('matches snapshot', () => {
const { toJSON } = render(<ProductCard {...defaultProps} />);
expect(toJSON()).toMatchSnapshot();
});
it('matches snapshot when on sale', () => {
const { toJSON } = render(
<ProductCard {...defaultProps} salePrice={79.99} />
);
expect(toJSON()).toMatchSnapshot();
});
it('matches snapshot when out of stock', () => {
const { toJSON } = render(
<ProductCard {...defaultProps} inStock={false} />
);
expect(toJSON()).toMatchSnapshot();
});
});
Snapshot Testing Best Practices:
- Use sparingly - they can become maintenance burden
- Keep snapshots small and focused
- Review snapshot changes carefully in PRs
- Consider inline snapshots for small components
Testing Utilities
Custom Render Function
Copy
// test-utils/render.tsx
import { render, RenderOptions } from '@testing-library/react-native';
import { ReactElement } from 'react';
import { NavigationContainer } from '@react-navigation/native';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { AuthProvider } from '@/context/AuthContext';
import { ThemeProvider } from '@/context/ThemeContext';
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
interface CustomRenderOptions extends Omit<RenderOptions, 'wrapper'> {
initialRoute?: string;
}
function AllProviders({ children }: { children: React.ReactNode }) {
return (
<QueryClientProvider client={queryClient}>
<AuthProvider>
<ThemeProvider>
<NavigationContainer>
{children}
</NavigationContainer>
</ThemeProvider>
</AuthProvider>
</QueryClientProvider>
);
}
export function customRender(
ui: ReactElement,
options?: CustomRenderOptions
) {
return render(ui, { wrapper: AllProviders, ...options });
}
// Re-export everything
export * from '@testing-library/react-native';
export { customRender as render };
Test Data Factories
Copy
// test-utils/factories.ts
import { faker } from '@faker-js/faker';
export function createUser(overrides = {}) {
return {
id: faker.string.uuid(),
email: faker.internet.email(),
name: faker.person.fullName(),
avatar: faker.image.avatar(),
createdAt: faker.date.past().toISOString(),
...overrides,
};
}
export function createProduct(overrides = {}) {
return {
id: faker.string.uuid(),
name: faker.commerce.productName(),
description: faker.commerce.productDescription(),
price: parseFloat(faker.commerce.price()),
image: faker.image.url(),
category: faker.commerce.department(),
inStock: faker.datatype.boolean(),
rating: faker.number.float({ min: 1, max: 5, fractionDigits: 1 }),
...overrides,
};
}
export function createProducts(count: number, overrides = {}) {
return Array.from({ length: count }, () => createProduct(overrides));
}
Code Coverage
Running Coverage
Copy
# Generate coverage report
npm test -- --coverage
# Watch mode with coverage
npm test -- --coverage --watchAll
# Coverage for specific files
npm test -- --coverage --collectCoverageFrom='src/components/**/*.tsx'
Coverage Configuration
Copy
// jest.config.js
module.exports = {
// ... other config
collectCoverage: true,
collectCoverageFrom: [
'src/**/*.{ts,tsx}',
'!src/**/*.d.ts',
'!src/**/*.stories.tsx',
'!src/**/index.ts',
'!src/types/**',
],
coverageDirectory: 'coverage',
coverageReporters: ['text', 'lcov', 'html'],
coverageThreshold: {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: 80,
},
// Per-file thresholds
'./src/components/': {
branches: 90,
functions: 90,
lines: 90,
statements: 90,
},
},
};
Best Practices
Test Behavior, Not Implementation
Focus on what the component does, not how it does it
Use Accessible Queries
Prefer getByRole, getByLabelText over getByTestId
Avoid Testing Implementation Details
Don’t test internal state or private methods
Keep Tests Independent
Each test should be able to run in isolation
Query Priority
Copy
// Preferred (most accessible)
screen.getByRole('button', { name: 'Submit' });
screen.getByLabelText('Email');
screen.getByPlaceholderText('Enter email');
screen.getByText('Welcome');
// Less preferred (implementation detail)
screen.getByTestId('submit-button');
Next Steps
Module 28: Integration Testing
Learn to test component interactions and navigation flows