Setup
Install Dependencies
npx expo install jest-expo jest @testing-library/react-native @testing-library/jest-native
Configure Jest
Create Jest config
jest.config.js
module.exports = {
preset: 'jest-expo',
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)'
],
setupFilesAfterEnv: ['<rootDir>/jest-setup.js'],
collectCoverageFrom: [
'app/**/*.{js,jsx,ts,tsx}',
'!app/**/*.test.{js,jsx,ts,tsx}',
'!app/_layout.tsx',
'!app/**/index.tsx',
],
};
Create setup file
jest-setup.js
import '@testing-library/jest-native/extend-expect';
// Mock expo-router
jest.mock('expo-router', () => ({
...jest.requireActual('expo-router'),
useRouter: () => ({
push: jest.fn(),
replace: jest.fn(),
back: jest.fn(),
}),
useLocalSearchParams: () => ({}),
useGlobalSearchParams: () => ({}),
}));
// Mock expo modules
jest.mock('expo-font');
jest.mock('expo-asset');
jest.mock('expo-constants', () => ({
expoConfig: {},
systemFonts: [],
}));
// Silence console errors in tests
global.console = {
...console,
error: jest.fn(),
warn: jest.fn(),
};
Testing Components
Basic Component Test
app/components/__tests__/Button.test.tsx
import { render, fireEvent, screen } from '@testing-library/react-native';
import { Button } from '../Button';
describe('Button', () => {
it('renders correctly', () => {
render(<Button title="Press me" />);
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('is disabled when disabled prop is true', () => {
render(<Button title="Press me" disabled />);
const button = screen.getByText('Press me');
expect(button).toBeDisabled();
});
});
Component with State
app/components/__tests__/Counter.test.tsx
import { render, fireEvent, screen, waitFor } from '@testing-library/react-native';
import { Counter } from '../Counter';
describe('Counter', () => {
it('increments counter', () => {
render(<Counter initialCount={0} />);
const increment = screen.getByText('Increment');
const counter = screen.getByTestId('counter-value');
expect(counter).toHaveTextContent('0');
fireEvent.press(increment);
expect(counter).toHaveTextContent('1');
fireEvent.press(increment);
expect(counter).toHaveTextContent('2');
});
it('decrements counter', () => {
render(<Counter initialCount={5} />);
const decrement = screen.getByText('Decrement');
const counter = screen.getByTestId('counter-value');
expect(counter).toHaveTextContent('5');
fireEvent.press(decrement);
expect(counter).toHaveTextContent('4');
});
it('resets counter', () => {
render(<Counter initialCount={10} />);
const reset = screen.getByText('Reset');
const counter = screen.getByTestId('counter-value');
fireEvent.press(reset);
expect(counter).toHaveTextContent('0');
});
});
Async Components
app/components/__tests__/UserProfile.test.tsx
import { render, screen, waitFor } from '@testing-library/react-native';
import { UserProfile } from '../UserProfile';
// Mock fetch
global.fetch = jest.fn();
describe('UserProfile', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('shows loading state', () => {
(fetch as jest.Mock).mockImplementation(() =>
new Promise(() => {}) // Never resolves
);
render(<UserProfile userId="123" />);
expect(screen.getByText('Loading...')).toBeTruthy();
});
it('displays user data after loading', async () => {
const mockUser = {
id: '123',
name: 'John Doe',
email: 'john@example.com',
};
(fetch as jest.Mock).mockResolvedValueOnce({
ok: true,
json: async () => mockUser,
});
render(<UserProfile userId="123" />);
await waitFor(() => {
expect(screen.getByText('John Doe')).toBeTruthy();
expect(screen.getByText('john@example.com')).toBeTruthy();
});
});
it('displays error message on failure', async () => {
(fetch as jest.Mock).mockRejectedValueOnce(
new Error('Network error')
);
render(<UserProfile userId="123" />);
await waitFor(() => {
expect(screen.getByText(/error/i)).toBeTruthy();
});
});
});
Testing Hooks
app/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(0));
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 Expo Router
Test navigation and routing with Expo Router’s testing utilities.Setup Router Tests
app/(tabs)/__tests__/layout.test.tsx
import { renderRouter, screen } from 'expo-router/testing-library';
import { Text } from 'react-native';
import { Tabs } from 'expo-router';
describe('Tab Layout', () => {
it('renders home tab by default', () => {
renderRouter({
_layout: () => <Tabs />,
index: () => <Text testID="home">Home</Text>,
profile: () => <Text testID="profile">Profile</Text>,
});
expect(screen.getByTestId('home')).toBeVisible();
});
it('navigates between tabs', () => {
const { router } = renderRouter({
_layout: () => <Tabs />,
index: () => <Text testID="home">Home</Text>,
profile: () => <Text testID="profile">Profile</Text>,
});
expect(screen.getByTestId('home')).toBeVisible();
router.push('/profile');
expect(screen.getByTestId('profile')).toBeVisible();
});
});
Testing Navigation
app/__tests__/navigation.test.tsx
import { renderRouter, screen } from 'expo-router/testing-library';
import { router } from 'expo-router';
import { act } from '@testing-library/react-native';
import { Text } from 'react-native';
describe('Navigation', () => {
it('can navigate forward and back', () => {
renderRouter({
index: () => <Text testID="index">Index</Text>,
'profile/[id]': () => <Text testID="profile">Profile</Text>,
});
expect(screen.getByTestId('index')).toBeVisible();
act(() => router.push('/profile/123'));
expect(screen.getByTestId('profile')).toBeVisible();
act(() => router.back());
expect(screen.getByTestId('index')).toBeVisible();
});
it('reads search params', () => {
renderRouter(
{
index: () => {
const { id, name } = useLocalSearchParams();
return (
<Text testID="params">
{id} - {name}
</Text>
);
},
},
{
initialUrl: '/?id=123&name=John',
}
);
expect(screen.getByTestId('params')).toHaveTextContent('123 - John');
});
});
Testing Native Modules
Mock Native Modules
__tests__/camera.test.tsx
import { render, fireEvent, waitFor } from '@testing-library/react-native';
import * as Camera from 'expo-camera';
import { CameraScreen } from '../CameraScreen';
// Mock the camera module
jest.mock('expo-camera', () => ({
Camera: 'Camera',
CameraType: { back: 'back', front: 'front' },
requestCameraPermissionsAsync: jest.fn(),
}));
describe('CameraScreen', () => {
it('requests permissions on mount', async () => {
const mockRequest = jest.fn().mockResolvedValue({
status: 'granted'
});
(Camera.requestCameraPermissionsAsync as jest.Mock) = mockRequest;
render(<CameraScreen />);
await waitFor(() => {
expect(mockRequest).toHaveBeenCalled();
});
});
it('shows permission denied message', async () => {
(Camera.requestCameraPermissionsAsync as jest.Mock)
.mockResolvedValue({ status: 'denied' });
render(<CameraScreen />);
await waitFor(() => {
expect(screen.getByText(/permission denied/i)).toBeTruthy();
});
});
});
Testing Expo Modules
modules/__tests__/MyModule.test.ts
import { NativeModules } from 'react-native';
import MyModule from '../MyModule';
// Mock native module
jest.mock('react-native', () => ({
NativeModules: {
ExpoMyModule: {
doSomething: jest.fn(),
getValue: jest.fn(),
},
},
}));
describe('MyModule', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('calls native method', () => {
MyModule.doSomething('test');
expect(NativeModules.ExpoMyModule.doSomething)
.toHaveBeenCalledWith('test');
});
it('returns value from native', async () => {
NativeModules.ExpoMyModule.getValue
.mockResolvedValue('result');
const result = await MyModule.getValue();
expect(result).toBe('result');
});
});
Snapshot Testing
Component Snapshots
app/components/__tests__/Card.test.tsx
import { render } from '@testing-library/react-native';
import { Card } from '../Card';
describe('Card', () => {
it('matches snapshot', () => {
const tree = render(
<Card title="Test Card" content="Test content" />
).toJSON();
expect(tree).toMatchSnapshot();
});
it('matches snapshot with different props', () => {
const tree = render(
<Card
title="Different Card"
content="Different content"
variant="outlined"
/>
).toJSON();
expect(tree).toMatchSnapshot();
});
});
Updating Snapshots
# Update all snapshots
jest --updateSnapshot
# Update specific test file
jest Card.test.tsx --updateSnapshot
# Interactive mode
jest --watch
# Press 'u' to update failing snapshots
Test Coverage
Generate Coverage Report
# Run tests with coverage
npm test -- --coverage
# Generate HTML report
npm test -- --coverage --coverageReporters=html
# Open report
open coverage/index.html
Coverage Configuration
jest.config.js
module.exports = {
preset: 'jest-expo',
collectCoverageFrom: [
'app/**/*.{js,jsx,ts,tsx}',
'!app/**/*.test.{js,jsx,ts,tsx}',
'!app/**/__tests__/**',
'!app/_layout.tsx',
],
coverageThresholds: {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: 80,
},
},
};
CI/CD Integration
GitHub Actions
.github/workflows/test.yml
name: Test
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup Node
uses: actions/setup-node@v3
with:
node-version: '18'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm test -- --coverage
- name: Upload coverage
uses: codecov/codecov-action@v3
with:
files: ./coverage/coverage-final.json
Best Practices
1. Use Test IDs
// Component
<View testID="user-card">
<Text testID="user-name">{user.name}</Text>
<Text testID="user-email">{user.email}</Text>
</View>
// Test
const card = screen.getByTestId('user-card');
const name = screen.getByTestId('user-name');
2. Test User Behavior, Not Implementation
// Bad: Testing implementation details
it('sets loading state to true', () => {
const { result } = renderHook(() => useFetch('/api'));
expect(result.current.loading).toBe(true);
});
// Good: Testing user-visible behavior
it('shows loading indicator', () => {
render(<DataComponent />);
expect(screen.getByText('Loading...')).toBeTruthy();
});
3. Arrange-Act-Assert Pattern
it('increments counter when button is pressed', () => {
// Arrange
render(<Counter initialCount={0} />);
const button = screen.getByText('Increment');
// Act
fireEvent.press(button);
// Assert
expect(screen.getByTestId('count')).toHaveTextContent('1');
});
4. Clean Up After Tests
describe('Component with side effects', () => {
let unsubscribe: () => void;
afterEach(() => {
unsubscribe?.();
jest.clearAllMocks();
});
it('subscribes to events', () => {
const { result } = renderHook(() => useEventSubscription());
unsubscribe = result.current.unsubscribe;
// Test...
});
});
Troubleshooting
Tests Timeout
// Increase timeout for slow tests
it('loads data', async () => {
// ...
}, 10000); // 10 second timeout
// Or globally
jest.setTimeout(10000);
Mock Not Working
// Ensure mock is before import
jest.mock('expo-camera');
import * as Camera from 'expo-camera';
// Or use doMock for dynamic mocks
jest.doMock('expo-camera', () => ({ /* mock */ }));
Act Warnings
// Wrap state updates in act()
import { act } from '@testing-library/react-native';
act(() => {
fireEvent.press(button);
});
// For async operations
await act(async () => {
await fetchData();
});
Next Steps
E2E Testing
Test full user workflows
Testing Router
Test Expo Router navigation
CI/CD
Automate testing in CI
Error Handling
Test error scenarios