Navigation: ← Dependency Injection | Performance →
Testing is critical for maintaining quality in React Native applications. The ecosystem provides tools for every level of the testing pyramid: Jest for unit tests, React Native Testing Library (RNTL) for component tests, and Detox or Maestro for end-to-end tests. Understanding what to test, how to mock native modules, and when to avoid snapshot testing separates senior developers from juniors in interviews.
This guide covers testing strategies, tooling, and patterns for React Native applications.
╱ E2E (Detox/Maestro) ╲ ← Few, slow, high confidence
╱ Integration Tests ╲ ← Moderate count
╱ Component Tests (RNTL) ╲ ← Many, fast
╱ Unit Tests (Jest) ╲ ← Most, fastest
| Level | Tool | Speed | Scope | When to Use |
|---|---|---|---|---|
| Unit | Jest | ~ms | Functions, hooks, services | Business logic, utilities |
| Component | RNTL | ~100ms | UI components | User interactions, rendering |
| Integration | Jest + RNTL | ~500ms | Feature flows | Multi-component workflows |
| E2E | Detox/Maestro | ~minutes | Full app | Critical user journeys |
Jest is the default test runner for React Native. Key configuration:
preset: 'react-native'— RN-specific transforms and mockssetupFilesAfterSetup— Global mocks and test utilitiestransformIgnorePatterns— Allow transforming specific node_modulesmoduleNameMapper— Mock assets and path aliases
RNTL follows Testing Library principles:
- Test behavior, not implementation
- Query by accessibility roles, labels, and text
- Simulate user interactions (press, type, scroll)
- Avoid testing internal state or component structure
React Native requires mocking:
- Native modules —
NativeModules, platform APIs - Navigation — React Navigation container and hooks
- Async storage — MMKV, AsyncStorage, Keychain
- Network — fetch, Axios, React Query
- Animations — Reanimated, Animated API
Detox runs tests on real simulators/emulators with gray-box testing — it synchronizes with the app's idle state before interacting. Requires native build configuration.
Maestro uses YAML-based test flows. Simpler setup than Detox, no native code changes required. Runs on simulators/emulators and cloud devices.
Snapshot tests capture rendered output and compare against stored snapshots. Useful for regression detection but prone to false positives and maintenance burden if overused.
// jest.config.js
module.exports = {
preset: 'react-native',
setupFilesAfterSetup: ['./jest.setup.js'],
transformIgnorePatterns: [
'node_modules/(?!(react-native|@react-native|@react-navigation|react-native-reanimated|@tanstack/react-query)/)',
],
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1',
'\\.(jpg|jpeg|png|gif|svg)$': '<rootDir>/__mocks__/fileMock.js',
},
collectCoverageFrom: [
'src/**/*.{ts,tsx}',
'!src/**/*.d.ts',
'!src/**/index.ts',
],
coverageThreshold: {
global: { branches: 70, functions: 70, lines: 70, statements: 70 },
},
};// jest.setup.js
import '@testing-library/react-native/extend-expect';
jest.mock('react-native-reanimated', () =>
require('react-native-reanimated/mock'),
);
jest.mock('@react-native-async-storage/async-storage', () =>
require('@react-native-async-storage/async-storage/jest/async-storage-mock'),
);
jest.mock('react-native/Libraries/Animated/NativeAnimatedHelper');
jest.mock('@react-navigation/native', () => ({
...jest.requireActual('@react-navigation/native'),
useNavigation: () => ({
navigate: jest.fn(),
goBack: jest.fn(),
reset: jest.fn(),
}),
useRoute: () => ({ params: {} }),
}));
global.__reanimatedWorkletInit = jest.fn();// __tests__/useCounter.test.ts
import { renderHook, act } from '@testing-library/react-native';
import { useCounter } from '../hooks/useCounter';
describe('useCounter', () => {
it('should initialize with default value', () => {
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);
});
it('should reset to initial value', () => {
const { result } = renderHook(() => useCounter(10));
act(() => {
result.current.increment();
result.current.increment();
result.current.reset();
});
expect(result.current.count).toBe(10);
});
});// __tests__/LoginScreen.test.tsx
import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react-native';
import { LoginScreen } from '../screens/LoginScreen';
const mockLogin = jest.fn();
jest.mock('../hooks/useAuth', () => ({
useAuth: () => ({
login: mockLogin,
isLoading: false,
error: null,
}),
}));
describe('LoginScreen', () => {
beforeEach(() => {
mockLogin.mockClear();
});
it('renders email and password inputs', () => {
render(<LoginScreen />);
expect(screen.getByLabelText('Email')).toBeTruthy();
expect(screen.getByLabelText('Password')).toBeTruthy();
expect(screen.getByRole('button', { name: 'Sign In' })).toBeTruthy();
});
it('shows validation error for empty email', async () => {
render(<LoginScreen />);
fireEvent.press(screen.getByRole('button', { name: 'Sign In' }));
await waitFor(() => {
expect(screen.getByText('Email is required')).toBeTruthy();
});
expect(mockLogin).not.toHaveBeenCalled();
});
it('calls login with credentials on valid submit', async () => {
mockLogin.mockResolvedValue(undefined);
render(<LoginScreen />);
fireEvent.changeText(screen.getByLabelText('Email'), 'user@example.com');
fireEvent.changeText(screen.getByLabelText('Password'), 'password123');
fireEvent.press(screen.getByRole('button', { name: 'Sign In' }));
await waitFor(() => {
expect(mockLogin).toHaveBeenCalledWith({
email: 'user@example.com',
password: 'password123',
});
});
});
it('disables submit button while loading', () => {
jest.doMock('../hooks/useAuth', () => ({
useAuth: () => ({
login: mockLogin,
isLoading: true,
error: null,
}),
}));
const { LoginScreen: LoadingLoginScreen } = require('../screens/LoginScreen');
render(<LoadingLoginScreen />);
expect(screen.getByRole('button', { name: 'Sign In' })).toBeDisabled();
});
});// __tests__/testUtils.tsx
import React from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { render, RenderOptions } from '@testing-library/react-native';
function createTestQueryClient(): QueryClient {
return new QueryClient({
defaultOptions: {
queries: { retry: false, gcTime: 0 },
mutations: { retry: false },
},
});
}
export function renderWithQueryClient(
ui: React.ReactElement,
options?: RenderOptions,
) {
const queryClient = createTestQueryClient();
return {
...render(
<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>,
options,
),
queryClient,
};
}
// Usage in test
it('displays products from API', async () => {
jest.spyOn(apiClient, 'get').mockResolvedValue({ data: mockProducts });
renderWithQueryClient(<ProductListScreen />);
await waitFor(() => {
expect(screen.getByText('Product 1')).toBeTruthy();
});
});// e2e/login.test.ts
describe('Login Flow', () => {
beforeAll(async () => {
await device.launchApp({ newInstance: true });
});
beforeEach(async () => {
await device.reloadReactNative();
});
it('should login 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('wrong@example.com');
await element(by.id('password-input')).typeText('wrongpassword');
await element(by.id('login-button')).tap();
await expect(element(by.text('Invalid credentials'))).toBeVisible();
});
});# e2e/login-flow.yaml
appId: com.myapp
---
- launchApp
- tapOn:
id: "email-input"
- inputText: "user@example.com"
- tapOn:
id: "password-input"
- inputText: "password123"
- tapOn:
id: "login-button"
- assertVisible:
id: "home-screen"
- assertVisible: "Welcome back"# e2e/checkout-flow.yaml
appId: com.myapp
---
- launchApp
- runFlow: login-flow.yaml
- tapOn: "Products"
- tapOn:
id: "product-card-1"
- tapOn: "Add to Cart"
- tapOn:
id: "cart-icon"
- assertVisible: "1 item"
- tapOn: "Checkout"
- assertVisible: "Order Summary"Answer:
The testing pyramid guides the ratio and scope of tests:
/ E2E \ 5-10% — Critical user journeys
/ Integr. \ 15-20% — Feature workflows
/ Component \ 30-35% — UI behavior
/ Unit \ 40-50% — Business logic
React Native mapping:
| Level | Tool | Example |
|---|---|---|
| Unit | Jest | formatCurrency(1234.5) → "$1,234.50" |
| Component | RNTL | Login form validates email, calls onSubmit |
| Integration | Jest + RNTL | Auth flow: login → fetch profile → display |
| E2E | Detox/Maestro | Full checkout flow on simulator |
Why this ratio:
- Unit tests are fast (~ms), cheap to maintain, catch logic bugs
- Component tests verify UI behavior without device overhead
- E2E tests are slow (~minutes), flaky, expensive — reserve for critical paths
React Native specifics:
- Mock native modules at unit/component level
- E2E tests run on simulators/emulators (not JSDOM)
- Bridge/native module behavior only testable at E2E or integration level
- Use MSW (Mock Service Worker) for API mocking at component level
Answer:
Basic configuration:
module.exports = {
preset: 'react-native',
setupFilesAfterSetup: ['./jest.setup.js'],
transformIgnorePatterns: [
'node_modules/(?!(react-native|@react-native|@react-navigation)/)',
],
};Key configuration areas:
1. Transform ignore patterns — By default, Jest ignores node_modules. RN packages ship untranspiled JSX/TS and must be transformed:
transformIgnorePatterns: [
'node_modules/(?!(react-native|@react-native|react-native-reanimated|@react-navigation|@tanstack)/)',
]2. Module name mapper — Handle path aliases and asset imports:
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1',
'\\.(png|svg)$': '<rootDir>/__mocks__/fileMock.js',
}3. Setup files — Global mocks for native modules:
// jest.setup.js
jest.mock('react-native-reanimated', () =>
require('react-native-reanimated/mock'),
);4. Coverage thresholds — Enforce minimum coverage:
coverageThreshold: {
global: { branches: 70, lines: 70 },
}Running tests:
npx jest # Run all tests
npx jest --watch # Watch mode
npx jest --coverage # With coverage report
npx jest src/features/auth # Specific directory
npx jest -t "should login" # By test nameAnswer:
RNTL follows the principle: "The more your tests resemble the way your software is used, the more confidence they can give you."
Query priority (most to least preferred):
getByRole('button', { name: 'Submit' })— Accessibility rolegetByLabelText('Email')— Form labelsgetByPlaceholderText('Enter email')— Placeholder textgetByText('Welcome')— Visible textgetByDisplayValue('user@example.com')— Input valuesgetByTestId('submit-btn')— Last resort — test IDs
Best practices:
// Good — query by role (accessible)
fireEvent.press(screen.getByRole('button', { name: 'Sign In' }));
// Avoid — query by test ID when accessible query exists
fireEvent.press(screen.getByTestId('login-button'));
// Good — wait for async updates
await waitFor(() => {
expect(screen.getByText('Success')).toBeTruthy();
});
// Avoid — testing implementation details
expect(component.state.isLoading).toBe(false);User interaction simulation:
fireEvent.press(button); // Tap
fireEvent.changeText(input, 'new text'); // Type
fireEvent(input, 'submitEditing'); // Enter key
fireEvent.scroll(flatList, { nativeEvent: { contentOffset: { y: 100 } } });Async patterns:
- Use
waitForfor state updates after interactions - Use
findBy*queries (waitFor + getBy combined) - Use
act()when testing hooks directly withrenderHook
Answer:
1. Native Modules:
jest.mock('react-native/Libraries/Animated/NativeAnimatedHelper');
jest.mock('react-native/Libraries/Utilities/Platform', () => ({
OS: 'ios',
select: jest.fn((obj) => obj.ios),
}));2. Navigation:
const mockNavigate = jest.fn();
jest.mock('@react-navigation/native', () => ({
useNavigation: () => ({ navigate: mockNavigate, goBack: jest.fn() }),
useRoute: () => ({ params: { userId: '123' } }),
}));3. Async Storage / MMKV:
jest.mock('@react-native-async-storage/async-storage', () =>
require('@react-native-async-storage/async-storage/jest/async-storage-mock'),
);4. Network (MSW — preferred):
import { setupServer } from 'msw/node';
import { http, HttpResponse } from 'msw';
const server = setupServer(
http.get('/api/users/1', () => {
return HttpResponse.json({ id: '1', name: 'Test User' });
}),
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());5. Reanimated:
jest.mock('react-native-reanimated', () =>
require('react-native-reanimated/mock'),
);6. Manual mock files (__mocks__/):
__mocks__/
├── react-native-keychain.ts
├── @react-native-community/netinfo.ts
└── react-native-permissions.ts
Key principle: Mock at the boundary (native modules, network, navigation), not internal functions. Test your code's behavior given external inputs/outputs.
Answer:
| Feature | Detox | Maestro |
|---|---|---|
| Test format | JavaScript/TypeScript | YAML flows |
| Setup complexity | High (native config) | Low (CLI install) |
| Synchronization | Gray-box (waits for idle) | Built-in smart waits |
| Platform support | iOS + Android | iOS + Android |
| Cloud CI | Detox Cloud (paid) | Maestro Cloud |
| Debugging | Screenshots, logs | Interactive studio |
| Learning curve | Steep | Gentle |
| Community | Established (Wix) | Growing (Mobile.dev) |
| React Native integration | Deep (designed for RN) | Framework-agnostic |
Detox advantages:
- Designed specifically for React Native
- Automatic synchronization with JS thread
- Full JavaScript — reuse helpers, fixtures, factories
- Mature CI integration
Maestro advantages:
- Zero native code changes
- YAML flows readable by non-developers (QA team)
- Faster initial setup
- Built-in cloud device farm
- Flow composition (
runFlow: login.yaml)
When to choose Detox:
- RN-only app with complex interactions
- Team comfortable with JS test code
- Need deep RN integration (bridge sync)
When to choose Maestro:
- Quick E2E setup needed
- QA team writes tests (YAML)
- Multi-platform (RN + native screens)
- Simpler test flows (login, navigation, forms)
Interview tip: Many teams use both — Maestro for smoke tests and QA-driven flows, Detox for developer-written complex scenarios.
Answer:
Snapshot tests render a component and compare output against a stored snapshot file. Common pitfalls:
1. False positives from cosmetic changes:
// Any style change breaks ALL snapshots containing this component
<Text style={{ fontSize: 16 }}>Hello</Text> // Change to 17 → snapshot fail2. Large, unreadable snapshots:
// 500-line snapshot for a complex component — nobody reviews the diff
expect(tree.toJSON()).toMatchSnapshot();3. Snapshot fatigue — developers blindly update:
jest -u # "Update all snapshots" — masks real regressions4. Testing implementation, not behavior: Snapshots capture the entire render tree including wrapper Views, styles, and internal structure — not what the user sees or does.
5. Platform-specific snapshots: iOS and Android render different native trees — need separate snapshots or platform-agnostic mocks.
When snapshots ARE useful:
- Stable, presentational components (icons, badges)
- Serialized data structures (API response normalization)
- Error message formats
- Small, focused output (< 20 lines)
Better alternatives:
// Instead of snapshot
expect(tree.toJSON()).toMatchSnapshot();
// Test specific behavior
expect(screen.getByText('Order #1234')).toBeTruthy();
expect(screen.getByRole('button', { name: 'Track Order' })).toBeTruthy();
expect(screen.queryByText('Cancelled')).toBeNull();Rule of thumb: If you're updating snapshots more than once a sprint, switch to behavioral assertions.
Answer:
CI pipeline stages:
# .github/workflows/test.yml
name: Test
on: [push, pull_request]
jobs:
unit-and-component:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- run: npm ci
- run: npx jest --coverage --ci --forceExit
- uses: codecov/codecov-action@v4
e2e-ios:
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npx pod-install
- run: npx detox build --configuration ios.sim.debug
- run: npx detox test --configuration ios.sim.debug --cleanupKey CI considerations:
1. Jest in CI:
npx jest --ci --forceExit --coverage --maxWorkers=2--ci— Non-interactive, fail on snapshot mismatch--forceExit— Prevent hanging from open handles--maxWorkers=2— Limit parallelism in CI environments
2. Coverage enforcement:
coverageThreshold: { global: { lines: 70 } }3. E2E in CI:
- Detox requires macOS for iOS, Linux emulator for Android
- Use Maestro Cloud for device farm without local simulators
- Record videos/screenshots on failure for debugging
4. Pre-commit hooks:
npx lint-staged # Run related tests on staged files5. Test parallelization:
npx jest --shard=1/3 # Split tests across CI matrix jobs6. Caching:
- Cache
node_modules, Metro cache, Gradle/CocoaPods - Hermes bytecode compilation is slow — cache build artifacts
Flaky test management:
- Retry E2E tests once on failure (not unit tests)
- Quarantine consistently flaky tests
- Track test reliability metrics over time
| Practice | Reason |
|---|---|
| Follow the testing pyramid | Fast feedback, maintainable test suite |
| Query by accessibility role/label | Tests resemble user behavior |
| Mock at boundaries, not internals | Tests survive refactoring |
| Use MSW for API mocking | Realistic network behavior without server |
| Avoid excessive snapshot tests | Reduce false positives and maintenance |
| Create test utility helpers | Consistent setup (renderWithQueryClient, etc.) |
| Run tests in CI on every PR | Catch regressions before merge |
| Test critical paths with E2E | Highest confidence for user journeys |
| Reset mocks in beforeEach | Prevent test pollution |
Use --forceExit in CI |
Prevent hanging from native module handles |
Navigation: ← Dependency Injection | Performance →