Testing mobile apps is harder than testing web apps. You are dealing with native module mocks, platform-specific behavior, asynchronous gesture handlers, and components that depend on device APIs. Many teams skip testing entirely because the setup feels daunting — and then pay for it with regression bugs that only surface after an App Store release (where a hotfix takes days, not minutes).This module covers unit testing with Jest and React Native Testing Library (RNTL), including component testing, hook testing, and mocking native modules. The goal is to give you a testing foundation that catches real bugs without becoming a maintenance burden.What You’ll Learn:
The transformIgnorePatterns setting below is the single most confusing part of React Native testing setup. By default, Jest does not transform code inside node_modules. But React Native packages ship untranspiled ES modules, so you must explicitly whitelist them. If you see “unexpected token import” errors in your tests, this regex is where to look.
// jest.config.jsmodule.exports = { preset: 'jest-expo', // or 'react-native' // Setup files -- runs after the test framework is installed but before tests setupFilesAfterEnv: ['<rootDir>/jest.setup.js'], // Module resolution -- match your tsconfig paths so imports work in tests moduleNameMapper: { '^@/(.*)$': '<rootDir>/src/$1', }, // Transform -- whitelist RN packages that ship ES modules. // This regex says: "transform everything in node_modules that matches // these package name patterns." Add packages here as you encounter // "SyntaxError: Unexpected token" errors. 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)', ], // Coverage collectCoverageFrom: [ 'src/**/*.{ts,tsx}', '!src/**/*.d.ts', '!src/**/*.stories.{ts,tsx}', '!src/**/index.{ts,tsx}', ], coverageThreshold: { global: { branches: 70, functions: 70, lines: 70, statements: 70, }, }, // Test environment testEnvironment: 'jsdom', // Test patterns testMatch: ['**/__tests__/**/*.test.{ts,tsx}', '**/*.test.{ts,tsx}'], // Module file extensions moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], // Clear mocks between tests clearMocks: true, // Verbose output verbose: true,};
React Native Testing Library (RNTL) follows the guiding principle: test the way your users interact with your app, not the implementation details. Instead of checking whether setState was called or a specific internal state variable changed, you query components by their visible text, accessibility labels, or roles — the same things a real user would see and interact with.This philosophy means your tests survive refactors. Rewrite a component’s internal logic from useState to useReducer? Your tests still pass because the user-facing behavior did not change.Install RNTL:
Custom hooks contain reusable logic, and testing them in isolation (without a wrapping component) ensures they work correctly regardless of which component consumes them. renderHook from RNTL gives you a lightweight way to exercise a hook’s API and verify its outputs.
This is where React Native testing diverges most from web testing. Native modules (camera, location, secure storage, biometrics) do not exist in the Jest test environment. You must provide mock implementations that simulate their behavior. The pattern is consistent: create a file in a __mocks__ directory that matches the module’s import path, and export mock functions that return predictable values.
Practical tip: Keep a __mocks__ directory at your project root and commit it to version control. When a teammate adds a new native dependency and the tests break, they can check this directory for the pattern to follow. A well-maintained mock directory is one of the highest-leverage testing investments you can make.
# Generate coverage reportnpm test -- --coverage# Watch mode with coveragenpm test -- --coverage --watchAll# Coverage for specific filesnpm test -- --coverage --collectCoverageFrom='src/components/**/*.tsx'
Different parts of your app demand different testing strategies. Spending the same effort on every component leads to either under-tested critical paths or over-tested presentational components.
What to Test
Strategy
Tools
ROI
Business logic (utils, transforms, validators)
Pure unit tests
Jest
Very high — fast, stable, catches regressions
Custom hooks (state logic, data fetching)
renderHook
RNTL + Jest
High — verifies hook API without component coupling
Interactive components (forms, buttons, modals)
Behavioral component tests
RNTL + fireEvent
High — catches UX regressions
Presentational components (cards, badges, layout)
Snapshot tests (sparingly)
Jest snapshots
Low-medium — fragile, high maintenance
Navigation flows (auth, onboarding, deep links)
Integration tests
RNTL with NavigationContainer
Medium — complex setup, but catches routing bugs
Full user journeys (login to checkout)
E2E tests
Detox or Maestro
Medium — slow, flaky, but irreplaceable for confidence
Decision framework for what to test first:
Start with business logic and hooks. These are fast to test, stable, and catch the bugs that actually matter (wrong calculations, broken state transitions, malformed API payloads).
Add behavioral tests for user-interactive components. Forms, modals, and multi-step flows deserve thorough testing because bugs here directly block users.
Use snapshots only for “golden” components. If a component’s visual output is its contract (e.g., a design system button), a snapshot is appropriate. For everything else, behavioral assertions are more resilient.
Save E2E tests for critical paths. Login, checkout, and data-destructive flows. These tests are expensive to write and maintain — invest only where a failure in production is catastrophic.
Testing components that use React Query requires wrapping them in a QueryClientProvider with specific test configuration. This is a common stumbling point because the default QueryClient retries failed queries (which makes test failures confusing and slow).
// test-utils/queryWrapper.tsximport { QueryClient, QueryClientProvider } from '@tanstack/react-query';export function createTestQueryClient() { return new QueryClient({ defaultOptions: { queries: { retry: false, // No retries in tests cacheTime: Infinity, // Prevent garbage collection during test staleTime: Infinity, // Data never goes stale during test }, mutations: { retry: false, }, }, // Silence query errors in test output logger: { log: console.log, warn: console.warn, error: () => {}, }, });}export function createQueryWrapper() { const queryClient = createTestQueryClient(); return ({ children }: { children: React.ReactNode }) => ( <QueryClientProvider client={queryClient}> {children} </QueryClientProvider> );}
// hooks/__tests__/useUsers.test.tsimport { renderHook, waitFor } from '@testing-library/react-native';import { useUsers } from '../useUsers';import { createQueryWrapper } from '@/test-utils/queryWrapper';// Mock the API clientjest.mock('@/services/api/client');describe('useUsers', () => { it('fetches and returns users', async () => { const mockUsers = [{ id: '1', name: 'Alice', email: 'alice@test.com' }]; (apiClient.get as jest.Mock).mockResolvedValueOnce({ data: mockUsers }); const { result } = renderHook(() => useUsers(), { wrapper: createQueryWrapper(), }); // Initially loading expect(result.current.isLoading).toBe(true); await waitFor(() => { expect(result.current.isLoading).toBe(false); }); expect(result.current.data).toEqual(mockUsers); });});
Components that use react-native-reanimated require special mock setup. Animations run on a separate thread and do not execute in the Jest environment. The common mistake is trying to assert on animated values — they will always be at their initial state in tests.
// In jest.setup.js, mock Reanimated BEFORE any test importsjest.mock('react-native-reanimated', () => { const Reanimated = require('react-native-reanimated/mock'); // Silence the "call" warning Reanimated.default.call = () => {}; return Reanimated;});// In your test, test the OUTCOME of animation, not the animation itselfit('shows content after expand animation', async () => { render(<Accordion title="FAQ" content="Answer here" />); fireEvent.press(screen.getByText('FAQ')); // Don't assert on animated translateY values. // Assert on visibility of the content. await waitFor(() => { expect(screen.getByText('Answer here')).toBeTruthy(); });});
jest.useFakeTimers() can cause subtle issues with React Native’s event loop and async operations. The most common trap: waitFor from RNTL uses real timers internally, so calling jest.advanceTimersByTime() inside a waitFor creates a deadlock.
// Wrong: this can deadlock or time outit('debounced search works', async () => { jest.useFakeTimers(); render(<SearchBar onSearch={mockSearch} />); fireEvent.changeText(screen.getByPlaceholderText('Search'), 'react'); // This often hangs because waitFor is polling with real timers // while fake timers are frozen await waitFor(() => { jest.advanceTimersByTime(500); expect(mockSearch).toHaveBeenCalledWith('react'); });});// Correct: advance timers outside waitForit('debounced search works', async () => { jest.useFakeTimers(); render(<SearchBar onSearch={mockSearch} />); fireEvent.changeText(screen.getByPlaceholderText('Search'), 'react'); // Advance past the debounce delay act(() => { jest.advanceTimersByTime(500); }); expect(mockSearch).toHaveBeenCalledWith('react'); jest.useRealTimers();});
React Native components often branch on Platform.OS. To test both branches, you need to mock the Platform module per test, which requires jest.resetModules() because Platform is cached.
Use the most user-centric query first. This ordering ensures your tests break only when user-visible behavior changes, not when you rename a CSS class or restructure a component tree:
Priority
Query
When to Use
1 (best)
getByRole
Buttons, links, inputs — anything with a semantic role
2
getByLabelText
Form inputs associated with a label
3
getByPlaceholderText
Inputs with placeholder text (weaker than label)
4
getByText
Non-interactive elements identified by visible text
5
getByDisplayValue
Inputs with a current value
6 (last resort)
getByTestId
When no accessible query applies — custom native views, complex layouts