Testing React Native Apps - A Comprehensive Guide
- React native
- Testing
- Mobile development
- Best practices
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.
Written by Lumina Software. Questions about anything here? Book a call

