Back to Posts
Testing React Native Apps - A Comprehensive Guide
By Lumina Software•
react-nativetestingmobile-developmentbest-practices
Testing React Native Apps - A Comprehensive Guide
Testing React Native apps requires a different approach than web apps. You're dealing with native modules, platform-specific code, and real device behavior. Here's how to build a comprehensive testing strategy.
Testing Pyramid
Unit Tests (Foundation)
Test individual functions and components in isolation:
// utils/formatDate.test.ts
import { formatDate } from './formatDate';
describe('formatDate', () => {
it('formats date correctly', () => {
const date = new Date('2025-10-26');
expect(formatDate(date)).toBe('Oct 26, 2025');
});
it('handles invalid dates', () => {
expect(() => formatDate(null)).toThrow();
});
});
Integration Tests (Middle Layer)
Test how components work together:
// components/TaskList.test.tsx
import { render, screen } from '@testing-library/react-native';
import { TaskList } from './TaskList';
describe('TaskList', () => {
it('renders tasks', () => {
const tasks = [
{ id: '1', title: 'Task 1', completed: false },
{ id: '2', title: 'Task 2', completed: true },
];
render(<TaskList tasks={tasks} />);
expect(screen.getByText('Task 1')).toBeTruthy();
expect(screen.getByText('Task 2')).toBeTruthy();
});
it('handles empty state', () => {
render(<TaskList tasks={[]} />);
expect(screen.getByText('No tasks')).toBeTruthy();
});
});
E2E Tests (Top Layer)
Test complete user flows:
// e2e/taskFlow.e2e.ts
describe('Task Flow', () => {
it('creates and completes a task', async () => {
await element(by.id('add-task-button')).tap();
await element(by.id('task-input')).typeText('New Task');
await element(by.id('save-button')).tap();
await expect(element(by.text('New Task'))).toBeVisible();
await element(by.id('task-checkbox')).tap();
await expect(element(by.id('completed-indicator'))).toBeVisible();
});
});
Testing Setup
Jest Configuration
// jest.config.js
module.exports = {
preset: 'react-native',
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
transformIgnorePatterns: [
'node_modules/(?!(react-native|@react-native|expo)/)',
],
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1',
},
collectCoverageFrom: [
'src/**/*.{ts,tsx}',
'!src/**/*.d.ts',
'!src/**/__tests__/**',
],
};
Testing Library Setup
// jest.setup.js
import '@testing-library/jest-native/extend-expect';
// Mock React Native modules
jest.mock('react-native/Libraries/Animated/NativeAnimatedHelper');
Component Testing
Basic Component Test
import { render, screen, fireEvent } from '@testing-library/react-native';
import { Button } from './Button';
describe('Button', () => {
it('renders with title', () => {
render(<Button title="Click me" onPress={() => {}} />);
expect(screen.getByText('Click me')).toBeTruthy();
});
it('calls onPress when pressed', () => {
const onPress = jest.fn();
render(<Button title="Click me" onPress={onPress} />);
fireEvent.press(screen.getByText('Click me'));
expect(onPress).toHaveBeenCalledTimes(1);
});
it('is disabled when disabled prop is true', () => {
render(<Button title="Click me" onPress={() => {}} disabled />);
const button = screen.getByText('Click me');
expect(button).toBeDisabled();
});
});
Testing Hooks
import { renderHook, act } from '@testing-library/react-native';
import { useCounter } from './useCounter';
describe('useCounter', () => {
it('increments count', () => {
const { result } = renderHook(() => useCounter());
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});
});
Testing Navigation
import { render } from '@testing-library/react-native';
import { NavigationContainer } from '@react-navigation/native';
import { createStackNavigator } from '@react-navigation/stack';
const Stack = createStackNavigator();
function renderWithNavigation(component: React.ReactElement) {
return render(
<NavigationContainer>
<Stack.Navigator>
<Stack.Screen name="Test" component={() => component} />
</Stack.Navigator>
</NavigationContainer>
);
}
describe('ProfileScreen', () => {
it('navigates to settings', () => {
const navigation = { navigate: jest.fn() };
renderWithNavigation(<ProfileScreen navigation={navigation} />);
fireEvent.press(screen.getByText('Settings'));
expect(navigation.navigate).toHaveBeenCalledWith('Settings');
});
});
API Testing
Mocking API Calls
// __mocks__/api.ts
export const fetchUser = jest.fn();
export const createPost = jest.fn();
// In test
import { fetchUser } from '@/api';
jest.mock('@/api');
describe('UserProfile', () => {
it('loads user data', async () => {
fetchUser.mockResolvedValue({ id: '1', name: 'John' });
render(<UserProfile userId="1" />);
await waitFor(() => {
expect(screen.getByText('John')).toBeTruthy();
});
});
});
Testing with TanStack Query
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { renderHook, waitFor } from '@testing-library/react-native';
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
function wrapper({ children }) {
return (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
);
}
describe('useUser', () => {
it('fetches user data', async () => {
fetchUser.mockResolvedValue({ id: '1', name: 'John' });
const { result } = renderHook(() => useUser('1'), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data.name).toBe('John');
});
});
E2E Testing with Detox
Setup
npm install --save-dev detox
// .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/App.app',
build: 'xcodebuild -workspace ios/App.xcworkspace -scheme App -configuration Debug -sdk iphonesimulator -derivedDataPath ios/build'
},
'android.debug': {
type: 'android.apk',
binaryPath: 'android/app/build/outputs/apk/debug/app-debug.apk',
build: 'cd android && ./gradlew assembleDebug assembleAndroidTest -DtestBuildType=debug'
}
},
devices: {
simulator: {
type: 'ios.simulator',
device: {
type: 'iPhone 14'
}
},
emulator: {
type: 'android.emulator',
device: {
avdName: 'Pixel_4_API_30'
}
}
}
};
E2E Test Example
// e2e/taskFlow.e2e.ts
describe('Task Flow', () => {
beforeAll(async () => {
await device.launchApp();
});
beforeEach(async () => {
await device.reloadReactNative();
});
it('should create a task', async () => {
await element(by.id('add-task-button')).tap();
await element(by.id('task-input')).typeText('New Task');
await element(by.id('save-button')).tap();
await expect(element(by.text('New Task'))).toBeVisible();
});
it('should complete a task', async () => {
await element(by.id('task-1')).tap();
await expect(element(by.id('completed-indicator'))).toBeVisible();
});
});
Snapshot Testing
Component Snapshots
import renderer from 'react-test-renderer';
describe('Button', () => {
it('matches snapshot', () => {
const tree = renderer
.create(<Button title="Click me" onPress={() => {}} />)
.toJSON();
expect(tree).toMatchSnapshot();
});
});
Use sparingly: Snapshots break easily and can become maintenance burden.
Performance Testing
Measuring Render Times
import { performance } from 'perf_hooks';
describe('Performance', () => {
it('renders list quickly', () => {
const start = performance.now();
render(<TaskList tasks={largeTaskList} />);
const duration = performance.now() - start;
expect(duration).toBeLessThan(100); // 100ms threshold
});
});
Platform-Specific Testing
Testing Platform Differences
import { Platform } from 'react-native';
describe('Platform-specific behavior', () => {
it('renders differently on iOS', () => {
Platform.OS = 'ios';
const { container } = render(<Component />);
expect(container).toHaveStyle({ paddingTop: 20 });
});
it('renders differently on Android', () => {
Platform.OS = 'android';
const { container } = render(<Component />);
expect(container).toHaveStyle({ paddingTop: 0 });
});
});
Testing Best Practices
- Test behavior, not implementation: Test what users see and do
- Keep tests simple: One assertion per test when possible
- Use descriptive names: Test names should explain what's tested
- Mock external dependencies: Don't rely on real APIs/databases
- Test edge cases: Empty states, errors, loading states
- Maintain test data: Use factories for test data
- Clean up: Reset state between tests
Test Data Factories
// factories/taskFactory.ts
export function createTask(overrides?: Partial<Task>): Task {
return {
id: '1',
title: 'Test Task',
completed: false,
createdAt: new Date(),
...overrides,
};
}
// In tests
const task = createTask({ title: 'Custom Task' });
Coverage Goals
Aim for:
- 80%+ overall coverage
- 100% coverage for critical paths
- High coverage for utilities and helpers
- Reasonable coverage for UI components
Continuous Integration
GitHub Actions Example
# .github/workflows/test.yml
name: Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
- run: npm install
- run: npm test -- --coverage
- run: npm run test:e2e
Conclusion
A comprehensive testing strategy includes:
- Unit tests: Fast, isolated tests
- Integration tests: Component interactions
- E2E tests: Complete user flows
- Performance tests: Ensure responsiveness
- Platform tests: Verify platform differences
Test early, test often, and test what matters. Good tests give you confidence to refactor and ship features quickly.
