Testing
React Native testing — unit tests, component tests, E2E tests, mock strategies, CI integration
Testing
Testing React Native is harder than testing web React. You have a native runtime, platform-specific behavior, animated transitions, and a bridge between JS and native that swallows errors silently. The payoff for getting it right is proportional: a broken mobile release takes days to fix through app store review, not minutes through a CDN redeploy.
This page covers what to test, how to test it, and where teams waste their time.
Testing Pyramid in React Native
┌───────────┐
│ E2E │ Slow, expensive, high confidence
│ (Detox) │
├─────────────┤
│ Component │ Medium speed, medium confidence
│ (RNTL) │
├───────────────────┤
│ Unit Tests │ Fast, cheap, targeted
│ (Jest + helpers) │
└───────────────────────┘How Mobile Differs from Web
| Concern | Web React | React Native |
|---|---|---|
| Runtime | Browser DOM | Native views (iOS/Android) via bridge |
| E2E tooling | Cypress / Playwright | Detox / Maestro (device or emulator required) |
| Release cadence | Deploy anytime | App store review cycle (1-3 days) |
| Cost of regression | Redeploy in minutes | Emergency review or hotfix with CodePush |
| Platform branching | Rarely needed | Constant (Platform.OS, .ios.tsx files) |
| Animation testing | CSS transitions | Reanimated worklets running on UI thread |
Because mobile releases are expensive to fix, the ROI of testing is higher than on web. But the E2E layer is also more expensive to run (emulators, device farms, build times), so getting the pyramid shape right matters more.
Cost/Speed/Confidence per Layer
| Layer | Run time | Setup cost | Confidence | What it catches |
|---|---|---|---|---|
| Unit | ~1ms per test | Minimal | Low-medium | Logic bugs, edge cases in pure functions |
| Component | ~50-200ms per test | Moderate (mocks) | Medium-high | UI regressions, interaction bugs, integration between hooks and views |
| E2E | ~30-120s per test | High (build + emulator) | High | Full-flow regressions, navigation, native module integration |
Aim for roughly 70% unit, 20% component, 10% E2E by test count. This is a guideline, not a law. A CRUD app with simple logic benefits more from component tests; a fintech app with complex calculations benefits more from unit tests. Let the codebase shape the ratio.
Unit Testing
Unit tests cover pure logic — the functions that take input and return output without touching React or native APIs.
Testing Pure Logic
// core/utils/formatCurrency.ts
export function formatCurrency(cents: number, currency = 'NZD'): string {
return new Intl.NumberFormat('en-NZ', {
style: 'currency',
currency,
}).format(cents / 100);
}
// core/utils/__tests__/formatCurrency.test.ts
import { formatCurrency } from '../formatCurrency';
describe('formatCurrency', () => {
it('formats cents to dollar string', () => {
expect(formatCurrency(1999)).toBe('$19.99');
});
it('handles zero', () => {
expect(formatCurrency(0)).toBe('$0.00');
});
it('handles negative amounts (refunds)', () => {
expect(formatCurrency(-500)).toBe('-$5.00');
});
it('supports other currencies', () => {
expect(formatCurrency(1000, 'USD')).toBe('US$10.00');
});
});Testing Custom Hooks
Use renderHook from @testing-library/react-native. The hook runs inside a real React tree, so state updates work correctly.
// features/auth/hooks/usePasswordStrength.ts
export function usePasswordStrength(password: string) {
return useMemo(() => {
if (password.length < 8) return 'weak';
const hasUpper = /[A-Z]/.test(password);
const hasNumber = /\d/.test(password);
const hasSpecial = /[!@#$%^&*]/.test(password);
const score = [hasUpper, hasNumber, hasSpecial].filter(Boolean).length;
if (score >= 3) return 'strong';
if (score >= 1) return 'medium';
return 'weak';
}, [password]);
}
// features/auth/hooks/__tests__/usePasswordStrength.test.ts
import { renderHook } from '@testing-library/react-native';
import { usePasswordStrength } from '../usePasswordStrength';
describe('usePasswordStrength', () => {
it('returns weak for short passwords', () => {
const { result } = renderHook(() => usePasswordStrength('abc'));
expect(result.current).toBe('weak');
});
it('returns strong when all criteria met', () => {
const { result } = renderHook(() => usePasswordStrength('P@ssw0rd!'));
expect(result.current).toBe('strong');
});
it('updates when password changes', () => {
const { result, rerender } = renderHook(
({ pw }) => usePasswordStrength(pw),
{ initialProps: { pw: 'abc' } },
);
expect(result.current).toBe('weak');
rerender({ pw: 'MyStr0ng!Pass' });
expect(result.current).toBe('strong');
});
});Testing Zustand Stores
Zustand stores are plain functions. Test them by calling actions and asserting on getState(). Use act for anything that triggers React state.
// features/cart/store/cartStore.ts
import { create } from 'zustand';
interface CartState {
items: Array<{ id: string; qty: number }>;
addItem: (id: string) => void;
removeItem: (id: string) => void;
clear: () => void;
}
export const useCartStore = create<CartState>((set, get) => ({
items: [],
addItem: (id) =>
set((s) => {
const existing = s.items.find((i) => i.id === id);
if (existing) {
return { items: s.items.map((i) => (i.id === id ? { ...i, qty: i.qty + 1 } : i)) };
}
return { items: [...s.items, { id, qty: 1 }] };
}),
removeItem: (id) => set((s) => ({ items: s.items.filter((i) => i.id !== id) })),
clear: () => set({ items: [] }),
}));
// features/cart/store/__tests__/cartStore.test.ts
import { useCartStore } from '../cartStore';
describe('cartStore', () => {
beforeEach(() => {
useCartStore.setState({ items: [] });
});
it('adds a new item', () => {
useCartStore.getState().addItem('sku-1');
expect(useCartStore.getState().items).toEqual([{ id: 'sku-1', qty: 1 }]);
});
it('increments qty for existing item', () => {
useCartStore.getState().addItem('sku-1');
useCartStore.getState().addItem('sku-1');
expect(useCartStore.getState().items).toEqual([{ id: 'sku-1', qty: 2 }]);
});
it('removes an item', () => {
useCartStore.getState().addItem('sku-1');
useCartStore.getState().removeItem('sku-1');
expect(useCartStore.getState().items).toEqual([]);
});
});Testing Redux Slices
Test slices in isolation by dispatching actions against a real store instance. No component rendering needed.
// features/order/store/orderSlice.test.ts
import { configureStore } from '@reduxjs/toolkit';
import { orderSlice, fetchOrders } from '../orderSlice';
function createTestStore() {
return configureStore({ reducer: { orders: orderSlice.reducer } });
}
describe('orderSlice', () => {
it('sets loading state on fetchOrders.pending', () => {
const store = createTestStore();
store.dispatch(fetchOrders.pending('req-1', undefined));
expect(store.getState().orders.status).toBe('loading');
});
it('stores orders on fetchOrders.fulfilled', () => {
const store = createTestStore();
const orders = [{ id: '1', total: 4999 }];
store.dispatch(fetchOrders.fulfilled(orders, 'req-1', undefined));
expect(store.getState().orders.items).toEqual(orders);
expect(store.getState().orders.status).toBe('succeeded');
});
});Testing TanStack Query Hooks
Wrap hooks in a QueryClientProvider with a fresh client per test. Disable retries to avoid flaky test timeouts.
// test/utils.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import type { ReactNode } from 'react';
export function createTestQueryClient() {
return new QueryClient({
defaultOptions: {
queries: { retry: false, gcTime: 0 },
mutations: { retry: false },
},
});
}
export function createQueryWrapper() {
const client = createTestQueryClient();
return function Wrapper({ children }: { children: ReactNode }) {
return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
};
}
// features/order/hooks/__tests__/useOrders.test.ts
import { renderHook, waitFor } from '@testing-library/react-native';
import { useOrders } from '../useOrders';
import { createQueryWrapper } from '../../../../test/utils';
import * as api from '../../api/orders';
jest.mock('../../api/orders');
describe('useOrders', () => {
it('returns orders from API', async () => {
const mockOrders = [{ id: '1', total: 1999 }];
(api.getOrders as jest.Mock).mockResolvedValue(mockOrders);
const { result } = renderHook(() => useOrders(), {
wrapper: createQueryWrapper(),
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toEqual(mockOrders);
});
it('exposes error state on failure', async () => {
(api.getOrders as jest.Mock).mockRejectedValue(new Error('Network error'));
const { result } = renderHook(() => useOrders(), {
wrapper: createQueryWrapper(),
});
await waitFor(() => expect(result.current.isError).toBe(true));
expect(result.current.error?.message).toBe('Network error');
});
});Async Testing Patterns
// waitFor retries until assertion passes or timeout
await waitFor(() => {
expect(screen.getByText('Order confirmed')).toBeTruthy();
});
// findBy = getBy + waitFor — preferred for single element queries
const heading = await screen.findByText('Order confirmed');
expect(heading).toBeTruthy();
// waitForElementToBeRemoved for loading states
await waitForElementToBeRemoved(() => screen.getByTestId('spinner'));Do not use act() manually unless you have a specific reason. Both render and fireEvent from RNTL already wrap in act. Adding extra act() calls masks real warnings and clutters test code.
Mocking Fundamentals
// jest.mock — replace an entire module
jest.mock('../../api/orders', () => ({
getOrders: jest.fn().mockResolvedValue([]),
}));
// jest.fn — standalone mock function
const onSubmit = jest.fn();
fireEvent.press(screen.getByText('Submit'));
expect(onSubmit).toHaveBeenCalledWith({ email: 'test@example.com' });
// jest.spyOn — partial mock, original preserved
const spy = jest.spyOn(analytics, 'track');
fireEvent.press(screen.getByText('Buy'));
expect(spy).toHaveBeenCalledWith('purchase', { amount: 1999 });
spy.mockRestore();Component Testing with React Native Testing Library
RNTL is the standard. It renders components into a lightweight tree (no native runtime, no emulator) and encourages testing behavior over implementation.
Core API
import { render, screen, fireEvent, waitFor } from '@testing-library/react-native';
// Render
render(<OrderCard order={mockOrder} onPress={onPress} />);
// Query (screen is the default container)
const title = screen.getByText('Order #1234');
const button = screen.getByRole('button', { name: 'Confirm' });
// Interact
fireEvent.press(button);
// Assert
expect(onPress).toHaveBeenCalledWith('1234');Query Priority
Pick the most accessible query first. This order is not arbitrary — it mirrors how users (and accessibility tools) find elements.
| Priority | Query | When to use |
|---|---|---|
| 1 | getByRole | Buttons, headings, text inputs — always first choice |
| 2 | getByText | Static text content |
| 3 | getByPlaceholderText | Text inputs when no label exists |
| 4 | getByDisplayValue | Inputs with current value |
| 5 | getByTestId | Last resort — no semantic way to query |
Over-reliance on getByTestId is a code smell. If you can only find an element by test ID, you likely have an accessibility gap. Add accessibilityRole and accessibilityLabel to your component, then query by role. Your tests and your users both benefit.
Testing Navigation Flows
Mock @react-navigation/native and assert that navigation methods are called correctly.
// __mocks__/@react-navigation/native.ts (or inline jest.mock)
const mockNavigate = jest.fn();
const mockGoBack = jest.fn();
jest.mock('@react-navigation/native', () => ({
...jest.requireActual('@react-navigation/native'),
useNavigation: () => ({ navigate: mockNavigate, goBack: mockGoBack }),
useRoute: () => ({ params: { orderId: '123' } }),
}));
// features/order/screens/__tests__/OrderDetailScreen.test.tsx
import { render, screen, fireEvent } from '@testing-library/react-native';
import { OrderDetailScreen } from '../OrderDetailScreen';
describe('OrderDetailScreen', () => {
beforeEach(() => jest.clearAllMocks());
it('navigates to edit on press', () => {
render(<OrderDetailScreen />);
fireEvent.press(screen.getByRole('button', { name: 'Edit Order' }));
expect(mockNavigate).toHaveBeenCalledWith('OrderEdit', { orderId: '123' });
});
it('goes back on cancel', () => {
render(<OrderDetailScreen />);
fireEvent.press(screen.getByText('Cancel'));
expect(mockGoBack).toHaveBeenCalled();
});
});Testing FlatList Rendering
RNTL renders FlatList items synchronously in test. You can assert on item content directly.
describe('OrderList', () => {
const orders = [
{ id: '1', title: 'Laptop', total: 249900 },
{ id: '2', title: 'Keyboard', total: 12900 },
];
it('renders all items', () => {
render(<OrderList orders={orders} />);
expect(screen.getByText('Laptop')).toBeTruthy();
expect(screen.getByText('Keyboard')).toBeTruthy();
});
it('calls onPress with correct order', () => {
const onPress = jest.fn();
render(<OrderList orders={orders} onOrderPress={onPress} />);
fireEvent.press(screen.getByText('Laptop'));
expect(onPress).toHaveBeenCalledWith('1');
});
it('shows empty state when no orders', () => {
render(<OrderList orders={[]} />);
expect(screen.getByText('No orders yet')).toBeTruthy();
});
});Testing Platform-Specific Code
// Override Platform.OS per test
jest.mock('react-native/Libraries/Utilities/Platform', () => ({
OS: 'ios',
select: jest.fn((obj) => obj.ios),
}));
// Or use Platform.select inline and test the branching
describe('PaymentButton', () => {
it('shows Apple Pay on iOS', () => {
jest.doMock('react-native/Libraries/Utilities/Platform', () => ({
OS: 'ios',
select: (obj: any) => obj.ios,
}));
const { PaymentButton } = require('../PaymentButton');
render(<PaymentButton />);
expect(screen.getByText('Pay with Apple Pay')).toBeTruthy();
});
});For .ios.tsx / .android.tsx file pairs, Jest resolves the platform file based on the platform setting in jest.config.js. You can run your test suite twice with different platforms using --config overrides or separate config files.
Testing Forms with react-hook-form
describe('CheckoutForm', () => {
it('shows validation errors for empty required fields', async () => {
render(<CheckoutForm onSubmit={jest.fn()} />);
fireEvent.press(screen.getByRole('button', { name: 'Place Order' }));
expect(await screen.findByText('Email is required')).toBeTruthy();
expect(await screen.findByText('Address is required')).toBeTruthy();
});
it('calls onSubmit with form data when valid', async () => {
const onSubmit = jest.fn();
render(<CheckoutForm onSubmit={onSubmit} />);
fireEvent.changeText(screen.getByPlaceholderText('Email'), 'user@test.com');
fireEvent.changeText(screen.getByPlaceholderText('Address'), '123 Main St');
fireEvent.press(screen.getByRole('button', { name: 'Place Order' }));
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith({
email: 'user@test.com',
address: '123 Main St',
});
});
});
});Testing Error Boundaries
// Suppress console.error noise in test output
const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
function ThrowingChild() {
throw new Error('Boom');
}
describe('AppErrorBoundary', () => {
afterEach(() => consoleSpy.mockRestore());
it('renders fallback UI on error', () => {
render(
<AppErrorBoundary>
<ThrowingChild />
</AppErrorBoundary>,
);
expect(screen.getByText('Something went wrong')).toBeTruthy();
expect(screen.getByRole('button', { name: 'Try Again' })).toBeTruthy();
});
});Snapshot Testing
Snapshots are useful in narrow cases and actively harmful in others.
| Use snapshots for | Avoid snapshots for |
|---|---|
| Design system primitives (Button, Badge, Input) | Entire screens |
| Catching unintended style changes | Anything with dynamic data |
| Small, stable components | Components that change frequently |
// Good: small, stable component
it('matches snapshot', () => {
const tree = render(<Badge variant="success" label="Active" />);
expect(tree.toJSON()).toMatchSnapshot();
});Giant snapshots become rubber-stamp updates. When a 500-line snapshot breaks, the reviewer approves the update without reading it. That is worse than having no test at all, because it gives false confidence. Keep snapshots under 30 lines. If you cannot, write behavioral assertions instead.
E2E Testing
E2E tests run on a real device or emulator. They build the app, launch it, and drive it through user flows. They are slow, expensive, and the only way to verify that JS, native modules, and OS-level behavior work together.
Detox Setup and Architecture
Detox is a gray-box testing framework: it knows about your app's internal state (idle/busy), which lets it wait automatically instead of sleeping.
# Install
npm install -D detox detox-cli
brew tap wix/brew && brew install applesimutils # iOS only
# Initialize (creates .detoxrc.js + e2e/ folder)
npx detox init
# Build and test
npx detox build --configuration ios.sim.debug
npx detox test --configuration ios.sim.debugWriting Detox Flows
// e2e/checkout.test.ts
describe('Checkout flow', () => {
beforeAll(async () => {
await device.launchApp({ newInstance: true });
});
beforeEach(async () => {
await device.reloadReactNative();
});
it('completes purchase end-to-end', async () => {
// Navigate to product
await element(by.text('Laptops')).tap();
await element(by.text('MacBook Air')).tap();
// Add to cart
await element(by.id('add-to-cart')).tap();
await expect(element(by.id('cart-badge'))).toHaveText('1');
// Checkout
await element(by.id('cart-icon')).tap();
await element(by.id('checkout-button')).tap();
// Fill form
await element(by.id('email-input')).typeText('test@example.com');
await element(by.id('address-input')).typeText('123 Main St');
await element(by.id('place-order')).tap();
// Confirm
await waitFor(element(by.text('Order Confirmed')))
.toBeVisible()
.withTimeout(10000);
});
});Maestro as a Simpler Alternative
Maestro uses YAML flows and requires zero native build tooling. It is a good choice for teams that want E2E coverage without investing in Detox infrastructure.
# e2e/flows/login.yaml
appId: com.myapp
---
- launchApp
- tapOn: "Email"
- inputText: "user@example.com"
- tapOn: "Password"
- inputText: "SecureP@ss1"
- tapOn: "Log In"
- assertVisible: "Welcome back"# Run locally
maestro test e2e/flows/login.yaml
# Run on CI with Maestro Cloud
maestro cloud --app-file app.apk e2e/flows/Test Data Strategies
| Strategy | Pros | Cons |
|---|---|---|
| Seed script before suite | Deterministic, fast | Needs a test API or DB access |
| Create via UI | Tests the real flow | Slow, flaky |
| Fixtures with reset | Repeatable | Couples tests to DB schema |
| Dedicated test environment | Isolated | Cost, drift from prod |
For most teams: run a seed API endpoint (behind a feature flag or test-only route) that creates a user and data for each test run, then tear down after.
Running E2E on CI
# GitHub Actions example for Detox iOS
e2e-ios:
runs-on: macos-14
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- run: brew tap wix/brew && brew install applesimutils
- run: npx detox build --configuration ios.sim.release
- run: npx detox test --configuration ios.sim.release --headless --cleanupE2E tests on CI are expensive. macOS runners cost 10x Linux runners on most CI providers. Run the full E2E suite on merge to main, not on every PR. Use a smaller smoke suite for PR checks.
Visual Regression Testing
Tools like Applitools and Percy capture screenshots and diff them pixel-by-pixel.
| Tool | Approach | Platform support |
|---|---|---|
| Applitools Eyes | AI-powered visual diff | iOS, Android, Web |
| Percy | Snapshot comparison | Primarily web, mobile via screenshots |
| Detox + jest-image-snapshot | Open-source, device screenshots | iOS, Android |
Visual regression testing catches styling regressions that behavioral tests miss (e.g., a button pushed off-screen by a layout change). Pair with behavioral E2E, do not replace it.
Mock Strategies
Mocking is necessary in React Native testing, but over-mocking destroys test value. Mock the boundary, test the behavior.
Mocking Native Modules
// jest.setup.ts — mock modules that crash outside a native runtime
jest.mock('react-native/Libraries/Animated/NativeAnimatedHelper');
jest.mock('@react-native-async-storage/async-storage', () =>
require('@react-native-async-storage/async-storage/jest/async-storage-mock'),
);
// Custom native module
jest.mock('react-native', () => {
const rn = jest.requireActual('react-native');
rn.NativeModules.MyBiometricModule = {
authenticate: jest.fn().mockResolvedValue({ success: true }),
isAvailable: jest.fn().mockResolvedValue(true),
};
return rn;
});Mocking Navigation
// Reusable mock factory
export function mockNavigation() {
const navigate = jest.fn();
const goBack = jest.fn();
const reset = jest.fn();
const setOptions = jest.fn();
jest.mock('@react-navigation/native', () => ({
...jest.requireActual('@react-navigation/native'),
useNavigation: () => ({ navigate, goBack, reset, setOptions }),
useFocusEffect: jest.fn((cb) => cb()),
}));
return { navigate, goBack, reset, setOptions };
}
// In test file
const { navigate } = mockNavigation();Mocking AsyncStorage / MMKV
// AsyncStorage — use the provided mock
jest.mock('@react-native-async-storage/async-storage', () =>
require('@react-native-async-storage/async-storage/jest/async-storage-mock'),
);
// MMKV — create a Map-backed mock
jest.mock('react-native-mmkv', () => {
const store = new Map<string, string>();
return {
MMKV: jest.fn().mockImplementation(() => ({
getString: (key: string) => store.get(key),
set: (key: string, value: string) => store.set(key, value),
delete: (key: string) => store.delete(key),
contains: (key: string) => store.has(key),
clearAll: () => store.clear(),
})),
};
});Mocking Fetch with MSW
MSW (Mock Service Worker) intercepts at the network level, so your fetch/axios code runs unchanged.
// test/mocks/server.ts
import { setupServer } from 'msw/node';
import { http, HttpResponse } from 'msw';
export const handlers = [
http.get('https://api.example.com/orders', () => {
return HttpResponse.json([
{ id: '1', title: 'Laptop', total: 249900 },
]);
}),
http.post('https://api.example.com/orders', async ({ request }) => {
const body = await request.json();
return HttpResponse.json({ id: '2', ...body }, { status: 201 });
}),
];
export const server = setupServer(...handlers);
// jest.setup.ts
import { server } from './test/mocks/server';
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());MSW v2 works with React Native. Earlier versions required a service worker (browser only), but v2's setupServer runs in Node and intercepts fetch directly. Use it for testing any hook or component that makes API calls.
Mocking Reanimated
Reanimated requires a mock setup because its worklets compile to native code that does not exist in the Jest environment.
// jest.setup.ts
require('react-native-reanimated').setUpTests();
// jest.config.js — also add the Reanimated babel plugin
module.exports = {
// ...
transformIgnorePatterns: [
'node_modules/(?!(react-native|@react-native|react-native-reanimated)/)',
],
setupFilesAfterSetup: ['./jest.setup.ts'],
};Mocking Platform.OS
// Per-test platform override
describe('PlatformBanner', () => {
const originalPlatform = Platform.OS;
afterEach(() => {
Object.defineProperty(Platform, 'OS', { value: originalPlatform });
});
it('shows iOS-specific banner', () => {
Object.defineProperty(Platform, 'OS', { value: 'ios' });
render(<PlatformBanner />);
expect(screen.getByText('Download from App Store')).toBeTruthy();
});
it('shows Android-specific banner', () => {
Object.defineProperty(Platform, 'OS', { value: 'android' });
render(<PlatformBanner />);
expect(screen.getByText('Get it on Google Play')).toBeTruthy();
});
});When NOT to Mock
Not everything should be mocked. Mocking what you own hides real bugs.
| Do not mock | Why |
|---|---|
| Your own utility functions | They are fast and deterministic — test them for real |
| State management hooks | Test the real store; mock only the API layer beneath |
| Component children | Render the real tree unless it pulls in native modules |
| React itself | Never mock useState, useEffect, etc. |
Rule of thumb: mock at the boundary between your code and the outside world (network, device APIs, native modules). Everything inside that boundary should run for real.
Testing Architecture
What to Test vs What Not to Test
| Worth testing | Not worth testing |
|---|---|
| Business logic (validators, formatters, calculations) | Third-party library internals |
| User-facing behavior (tap button, see result) | Implementation details (internal state shape) |
| Error states (network failure, empty data) | That React calls useEffect |
| Edge cases (empty arrays, null values, long strings) | Styling (unless visual regression) |
| Navigation flows (user taps, arrives at correct screen) | That a console.log fires |
jest.config.js for React Native
// jest.config.js
module.exports = {
preset: 'react-native',
setupFilesAfterSetup: ['./jest.setup.ts'],
transformIgnorePatterns: [
'node_modules/(?!(' +
'react-native|' +
'@react-native|' +
'@react-navigation|' +
'react-native-reanimated|' +
'react-native-gesture-handler|' +
'react-native-screens|' +
'react-native-safe-area-context|' +
'react-native-mmkv|' +
'@shopify/flash-list' +
')/)',
],
moduleNameMapper: {
'\\.svg$': '<rootDir>/test/__mocks__/svgMock.ts',
'\\.(jpg|jpeg|png|gif|webp)$': '<rootDir>/test/__mocks__/fileMock.ts',
},
collectCoverageFrom: [
'src/**/*.{ts,tsx}',
'!src/**/*.d.ts',
'!src/**/index.ts', // barrel files
'!src/**/*.stories.tsx', // storybook
],
coverageThresholds: {
global: {
branches: 70,
functions: 70,
lines: 80,
statements: 80,
},
},
};transformIgnorePatterns is the most common source of Jest failures in React Native. React Native packages ship untranspiled ESM. If Jest throws SyntaxError: Unexpected token export, add the offending package to the ignore-pattern exception list. Every new native dependency is a potential addition.
Shared Test Utilities
Build a renderWithProviders wrapper that includes everything your components need at runtime.
// test/utils.tsx
import { render, type RenderOptions } from '@testing-library/react-native';
import { NavigationContainer } from '@react-navigation/native';
import { QueryClientProvider } from '@tanstack/react-query';
import { ThemeProvider } from '../src/app/theme';
import { createTestQueryClient } from './queryUtils';
interface ExtendedRenderOptions extends RenderOptions {
initialRoute?: string;
queryClient?: QueryClient;
}
export function renderWithProviders(
ui: React.ReactElement,
options: ExtendedRenderOptions = {},
) {
const { queryClient = createTestQueryClient(), ...renderOptions } = options;
function Wrapper({ children }: { children: React.ReactNode }) {
return (
<QueryClientProvider client={queryClient}>
<ThemeProvider>
<NavigationContainer>{children}</NavigationContainer>
</ThemeProvider>
</QueryClientProvider>
);
}
return {
...render(ui, { wrapper: Wrapper, ...renderOptions }),
queryClient,
};
}
// Usage in tests
import { renderWithProviders } from '../../../test/utils';
it('renders order list', async () => {
renderWithProviders(<OrderListScreen />);
expect(await screen.findByText('Your Orders')).toBeTruthy();
});Test Factories
Use factories to build test data without repeating boilerplate.
// test/factories.ts
let idCounter = 0;
export function buildOrder(overrides: Partial<Order> = {}): Order {
idCounter += 1;
return {
id: `order-${idCounter}`,
title: `Test Order ${idCounter}`,
total: 9999,
status: 'pending',
createdAt: new Date().toISOString(),
...overrides,
};
}
export function buildUser(overrides: Partial<User> = {}): User {
idCounter += 1;
return {
id: `user-${idCounter}`,
email: `user${idCounter}@test.com`,
name: 'Test User',
...overrides,
};
}Code Coverage Strategy
Coverage is a useful signal when treated as a floor, not a target. Gaming coverage with meaningless tests is worse than low coverage with meaningful tests.
| Metric | Target | Rationale |
|---|---|---|
| Line coverage | 80% | Catches dead code and untested branches |
| Branch coverage | 70% | Ensures error paths are exercised |
| Function coverage | 70% | Catches exported functions that nothing tests |
| Per-feature minimum | 60% | Prevents entire features from shipping untested |
Enforce coverage in CI as a ratchet: the threshold only goes up, never down. Add new code with coverage; do not mandate retroactive coverage for legacy code.
Anti-Patterns
Testing Implementation Details
// Bad: testing internal state shape
it('sets isLoading to true', () => {
const { result } = renderHook(() => useOrders());
expect(result.current.isLoading).toBe(true); // couples to hook internals
});
// Good: testing what the user sees
it('shows loading spinner', () => {
render(<OrderListScreen />);
expect(screen.getByTestId('loading-spinner')).toBeTruthy();
});Testing state shape means your test breaks whenever you refactor internals, even if behavior is unchanged. Test what the user sees or what the caller receives.
Over-Mocking
// Bad: mocking your own utility
jest.mock('../utils/formatCurrency');
// Now you are testing that your component calls formatCurrency,
// not that it displays the right price
// Good: let formatCurrency run for real, assert on displayed output
expect(screen.getByText('$19.99')).toBeTruthy();Snapshot Sprawl
Symptoms: snapshots over 100 lines, frequent --updateSnapshot runs, reviewers approving without reading diffs. The fix is to delete the snapshot test and replace it with targeted assertions on the behavior you actually care about.
Flaky Tests from Animation Timers
React Native animations (Animated, Reanimated, LayoutAnimation) use timers that interact poorly with Jest's fake timer system.
// Fix 1: disable animations in test
// jest.setup.ts
jest.mock('react-native/Libraries/Animated/NativeAnimatedHelper');
// Fix 2: use jest.useFakeTimers with advanceTimersByTime
jest.useFakeTimers();
fireEvent.press(screen.getByText('Toggle'));
jest.advanceTimersByTime(300); // skip past animation duration
expect(screen.getByText('Panel Content')).toBeTruthy();
jest.useRealTimers();E2E Tests That Depend on External Services
E2E tests that hit real third-party APIs (payment gateways, email providers, analytics) are guaranteed to flake. Use test doubles:
- Payment: Stripe test mode with test card numbers
- Push notifications: Mock the native module, verify the call
- Analytics: Mock the SDK, assert events are tracked
- Email: Use a test inbox service (Mailosaur, MailSlurp) or skip verification
If a third-party service has a sandbox/test mode, use it. If it does not, mock it at the native module boundary and accept that the integration is not covered by automated tests. That gap is what manual QA and staged rollouts are for.