Comprehensive testing is essential for building reliable React Native applications. This guide covers the complete testing pyramid from unit tests to end-to-end testing, including setup, best practices, and CI/CD integration.
1. Testing Pyramid Overview
A well-structured testing strategy follows the testing pyramid principle:
Unit Tests
70% of tests
Fast, isolated, component logicIntegration Tests
20% of tests
Component interactionsE2E Tests
10% of tests
Full user workflows2. Unit Testing with Jest
Jest Setup
Configure Jest for React Native testing:
// jest.config.js
module.exports = {
preset: 'react-native',
setupFilesAfterEnv: [
'<rootDir>/src/test-utils/setup.js'
],
transformIgnorePatterns: [
'node_modules/(?!(react-native|@react-native|react-clone-referenced-element|@react-navigation)/)'
],
collectCoverageFrom: [
'src/**/*.{js,jsx,ts,tsx}',
'!src/**/*.d.ts',
'!src/test-utils/**',
],
coverageThreshold: {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: 80
}
}
};
Testing Components
Test React Native components with React Native Testing Library:
import React from 'react';
import { render, fireEvent, waitFor } from '@testing-library/react-native';
import { UserProfile } from '../UserProfile';
describe('UserProfile', () => {
const mockUser = {
id: '1',
name: 'John Doe',
email: 'john@example.com'
};
it('renders user information correctly', () => {
const { getByText, getByTestId } = render(
<UserProfile user={mockUser} />
);
expect(getByText('John Doe')).toBeTruthy();
expect(getByText('john@example.com')).toBeTruthy();
expect(getByTestId('user-avatar')).toBeTruthy();
});
it('calls onEdit when edit button is pressed', () => {
const mockOnEdit = jest.fn();
const { getByText } = render(
<UserProfile user={mockUser} onEdit={mockOnEdit} />
);
fireEvent.press(getByText('Edit'));
expect(mockOnEdit).toHaveBeenCalledWith(mockUser);
});
});
3. Testing Custom Hooks
Hook Testing Strategy
Use @testing-library/react-hooks for testing custom hooks:
import { renderHook, act } from '@testing-library/react-native';
import { useCounter } from '../useCounter';
describe('useCounter', () => {
it('should initialize with 0', () => {
const { result } = renderHook(() => useCounter());
expect(result.current.count).toBe(0);
});
it('should increment count', () => {
const { result } = renderHook(() => useCounter());
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});
it('should decrement count', () => {
const { result } = renderHook(() => useCounter(5));
act(() => {
result.current.decrement();
});
expect(result.current.count).toBe(4);
});
});
4. Mocking External Dependencies
API Mocking
Mock API calls and external services:
// __mocks__/api.js
export const api = {
getUser: jest.fn(),
updateUser: jest.fn(),
deleteUser: jest.fn(),
};
// UserService.test.js
import { api } from '../api';
import { UserService } from '../UserService';
jest.mock('../api');
describe('UserService', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should fetch user data', async () => {
const mockUser = { id: '1', name: 'John' };
api.getUser.mockResolvedValue(mockUser);
const result = await UserService.fetchUser('1');
expect(api.getUser).toHaveBeenCalledWith('1');
expect(result).toEqual(mockUser);
});
it('should handle API errors', async () => {
api.getUser.mockRejectedValue(new Error('Network error'));
await expect(UserService.fetchUser('1'))
.rejects.toThrow('Network error');
});
});
React Navigation Mocking
Mock navigation for testing components that use navigation:
// test-utils/navigation-mock.js
export const mockNavigation = {
navigate: jest.fn(),
goBack: jest.fn(),
push: jest.fn(),
pop: jest.fn(),
reset: jest.fn(),
};
// Component.test.js
import { mockNavigation } from '../test-utils/navigation-mock';
describe('NavigationComponent', () => {
it('navigates to details on button press', () => {
const { getByText } = render(
<NavigationComponent navigation={mockNavigation} />
);
fireEvent.press(getByText('Go to Details'));
expect(mockNavigation.navigate).toHaveBeenCalledWith('Details', {
id: '123'
});
});
});
5. E2E Testing with Detox
Detox Setup
Configure Detox for end-to-end testing:
// .detoxrc.js
module.exports = {
testRunner: {
args: {
'$0': 'jest',
config: 'e2e/jest.config.js'
},
jest: {
setupTimeout: 120000
}
},
apps: {
'ios.debug': {
type: 'ios.app',
binaryPath: 'ios/build/Build/Products/Debug-iphonesimulator/YourApp.app'
},
'android.debug': {
type: 'android.apk',
binaryPath: 'android/app/build/outputs/apk/debug/app-debug.apk'
}
},
devices: {
simulator: {
type: 'ios.simulator',
device: {
type: 'iPhone 14'
}
},
emulator: {
type: 'android.emulator',
device: {
avdName: 'Pixel_4_API_30'
}
}
},
configurations: {
'ios.sim.debug': {
device: 'simulator',
app: 'ios.debug'
},
'android.emu.debug': {
device: 'emulator',
app: 'android.debug'
}
}
};
E2E Test Examples
Write comprehensive end-to-end tests:
// e2e/login.e2e.js
describe('Login Flow', () => {
beforeAll(async () => {
await device.launchApp();
});
beforeEach(async () => {
await device.reloadReactNative();
});
it('should login successfully with valid credentials', async () => {
await element(by.id('email-input')).typeText('user@example.com');
await element(by.id('password-input')).typeText('password123');
await element(by.id('login-button')).tap();
await waitFor(element(by.id('home-screen')))
.toBeVisible()
.withTimeout(5000);
await expect(element(by.text('Welcome back!'))).toBeVisible();
});
it('should show error for invalid credentials', async () => {
await element(by.id('email-input')).typeText('invalid@example.com');
await element(by.id('password-input')).typeText('wrongpassword');
await element(by.id('login-button')).tap();
await waitFor(element(by.text('Invalid credentials')))
.toBeVisible()
.withTimeout(3000);
});
});
6. CI/CD Integration
GitHub Actions Setup
Automate testing in your CI/CD pipeline:
# .github/workflows/test.yml
name: Test
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main ]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- 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
uses: codecov/codecov-action@v3
with:
file: ./coverage/lcov.info
e2e-test:
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '18'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Setup iOS Simulator
run: |
xcrun simctl create iPhone14 com.apple.CoreSimulator.SimDeviceType.iPhone-14
xcrun simctl boot iPhone14
- name: Build iOS app
run: npx react-native run-ios --configuration Release --simulator
- name: Run Detox tests
run: npx detox test --configuration ios.sim.release
7. Testing Best Practices
✅ Do
- • Test user behavior, not implementation
- • Use testID for reliable element selection
- • Mock external dependencies
- • Keep tests isolated and independent
- • Use descriptive test names
- • Test error states and edge cases
❌ Don't
- • Test implementation details
- • Use shallow rendering for integration tests
- • Make tests dependent on each other
- • Ignore async behavior
- • Test third-party library logic
- • Write overly complex test setups
8. Test Coverage and Quality Metrics
Monitor your testing effectiveness with key metrics:
85%
Code Coverage2.3s
Test Execution Time156
Total Tests98%
Pass RateConclusion
A comprehensive testing strategy is essential for building reliable React Native applications. Start with unit tests for core logic, add integration tests for component interactions, and implement E2E tests for critical user journeys.
Remember that testing is an investment in code quality and developer confidence. Well-tested code is easier to refactor, debug, and maintain over time.