Skip to main content

Documentation Index

Fetch the complete documentation index at: https://resources.devweekends.com/llms.txt

Use this file to discover all available pages before exploring further.

Unit Testing

Module Overview

Estimated Time: 4 hours | Difficulty: Intermediate | Prerequisites: Core React Native concepts
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:
  • Jest configuration for React Native
  • Testing components with RNTL
  • Mocking native modules
  • Testing hooks and context
  • Snapshot testing
  • Code coverage

Jest Setup

Default Configuration

React Native and Expo projects come with Jest pre-configured:
// package.json
{
  "scripts": {
    "test": "jest",
    "test:watch": "jest --watch",
    "test:coverage": "jest --coverage"
  },
  "jest": {
    "preset": "jest-expo"
  }
}

Custom Jest Configuration

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.js
module.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,
};

Setup File

// jest.setup.js
import '@testing-library/jest-native/extend-expect';

// Mock react-native-reanimated
jest.mock('react-native-reanimated', () => {
  const Reanimated = require('react-native-reanimated/mock');
  Reanimated.default.call = () => {};
  return Reanimated;
});

// Mock expo-font
jest.mock('expo-font', () => ({
  loadAsync: jest.fn(),
  isLoaded: jest.fn(() => true),
}));

// Mock AsyncStorage
jest.mock('@react-native-async-storage/async-storage', () =>
  require('@react-native-async-storage/async-storage/jest/async-storage-mock')
);

// Mock navigation
jest.mock('@react-navigation/native', () => {
  const actualNav = jest.requireActual('@react-navigation/native');
  return {
    ...actualNav,
    useNavigation: () => ({
      navigate: jest.fn(),
      goBack: jest.fn(),
      setOptions: jest.fn(),
    }),
    useRoute: () => ({
      params: {},
    }),
    useFocusEffect: jest.fn(),
  };
});

// Silence console warnings in tests
global.console = {
  ...console,
  warn: jest.fn(),
  error: jest.fn(),
};

// Mock timers
jest.useFakeTimers();

React Native Testing Library

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:
npm install --save-dev @testing-library/react-native @testing-library/jest-native

Basic Component Testing

// components/Button.tsx
import { Pressable, Text, StyleSheet } from 'react-native';

interface ButtonProps {
  title: string;
  onPress: () => void;
  disabled?: boolean;
  testID?: string;
}

export function Button({ title, onPress, disabled, testID }: ButtonProps) {
  return (
    <Pressable
      style={[styles.button, disabled && styles.disabled]}
      onPress={onPress}
      disabled={disabled}
      testID={testID}
      accessibilityRole="button"
      accessibilityState={{ disabled }}
    >
      <Text style={styles.text}>{title}</Text>
    </Pressable>
  );
}

const styles = StyleSheet.create({
  button: {
    backgroundColor: '#3b82f6',
    padding: 12,
    borderRadius: 8,
    alignItems: 'center',
  },
  disabled: {
    opacity: 0.5,
  },
  text: {
    color: '#fff',
    fontWeight: '600',
  },
});
// components/__tests__/Button.test.tsx
import { render, screen, fireEvent } from '@testing-library/react-native';
import { Button } from '../Button';

describe('Button', () => {
  it('renders correctly with title', () => {
    render(<Button title="Press Me" onPress={() => {}} />);
    
    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('does not call onPress when disabled', () => {
    const onPress = jest.fn();
    render(<Button title="Press Me" onPress={onPress} disabled />);
    
    fireEvent.press(screen.getByText('Press Me'));
    
    expect(onPress).not.toHaveBeenCalled();
  });

  it('has correct accessibility props', () => {
    render(<Button title="Submit" onPress={() => {}} />);
    
    const button = screen.getByRole('button');
    expect(button).toBeTruthy();
  });

  it('shows disabled state in accessibility', () => {
    render(<Button title="Submit" onPress={() => {}} disabled />);
    
    const button = screen.getByRole('button');
    expect(button).toBeDisabled();
  });
});

Testing with User Events

// components/__tests__/LoginForm.test.tsx
import { render, screen, fireEvent, waitFor } from '@testing-library/react-native';
import { LoginForm } from '../LoginForm';

describe('LoginForm', () => {
  const mockOnSubmit = jest.fn();

  beforeEach(() => {
    mockOnSubmit.mockClear();
  });

  it('renders all form fields', () => {
    render(<LoginForm onSubmit={mockOnSubmit} />);
    
    expect(screen.getByPlaceholderText('Email')).toBeTruthy();
    expect(screen.getByPlaceholderText('Password')).toBeTruthy();
    expect(screen.getByText('Login')).toBeTruthy();
  });

  it('updates input values on change', () => {
    render(<LoginForm onSubmit={mockOnSubmit} />);
    
    const emailInput = screen.getByPlaceholderText('Email');
    const passwordInput = screen.getByPlaceholderText('Password');
    
    fireEvent.changeText(emailInput, 'test@example.com');
    fireEvent.changeText(passwordInput, 'password123');
    
    expect(emailInput.props.value).toBe('test@example.com');
    expect(passwordInput.props.value).toBe('password123');
  });

  it('submits form with correct values', async () => {
    render(<LoginForm onSubmit={mockOnSubmit} />);
    
    fireEvent.changeText(screen.getByPlaceholderText('Email'), 'test@example.com');
    fireEvent.changeText(screen.getByPlaceholderText('Password'), 'password123');
    fireEvent.press(screen.getByText('Login'));
    
    await waitFor(() => {
      expect(mockOnSubmit).toHaveBeenCalledWith({
        email: 'test@example.com',
        password: 'password123',
      });
    });
  });

  it('shows validation errors for empty fields', async () => {
    render(<LoginForm onSubmit={mockOnSubmit} />);
    
    fireEvent.press(screen.getByText('Login'));
    
    await waitFor(() => {
      expect(screen.getByText('Email is required')).toBeTruthy();
      expect(screen.getByText('Password is required')).toBeTruthy();
    });
    
    expect(mockOnSubmit).not.toHaveBeenCalled();
  });

  it('shows error for invalid email', async () => {
    render(<LoginForm onSubmit={mockOnSubmit} />);
    
    fireEvent.changeText(screen.getByPlaceholderText('Email'), 'invalid-email');
    fireEvent.changeText(screen.getByPlaceholderText('Password'), 'password123');
    fireEvent.press(screen.getByText('Login'));
    
    await waitFor(() => {
      expect(screen.getByText('Invalid email address')).toBeTruthy();
    });
  });

  it('shows loading state during submission', async () => {
    mockOnSubmit.mockImplementation(() => new Promise(resolve => setTimeout(resolve, 1000)));
    
    render(<LoginForm onSubmit={mockOnSubmit} />);
    
    fireEvent.changeText(screen.getByPlaceholderText('Email'), 'test@example.com');
    fireEvent.changeText(screen.getByPlaceholderText('Password'), 'password123');
    fireEvent.press(screen.getByText('Login'));
    
    expect(screen.getByTestId('loading-indicator')).toBeTruthy();
    
    await waitFor(() => {
      expect(screen.queryByTestId('loading-indicator')).toBeNull();
    });
  });
});

Testing Hooks

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.

Custom Hook Testing

// hooks/useCounter.ts
import { useState, useCallback } from 'react';

export function useCounter(initialValue = 0) {
  const [count, setCount] = useState(initialValue);

  const increment = useCallback(() => setCount(c => c + 1), []);
  const decrement = useCallback(() => setCount(c => c - 1), []);
  const reset = useCallback(() => setCount(initialValue), [initialValue]);

  return { count, increment, decrement, reset };
}
// 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());
    
    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 Async Hooks

// hooks/useApi.ts
import { useState, useEffect } from 'react';

interface UseApiResult<T> {
  data: T | null;
  loading: boolean;
  error: Error | null;
  refetch: () => void;
}

export function useApi<T>(url: string): UseApiResult<T> {
  const [data, setData] = useState<T | null>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<Error | null>(null);

  const fetchData = async () => {
    setLoading(true);
    setError(null);
    try {
      const response = await fetch(url);
      if (!response.ok) throw new Error('Failed to fetch');
      const json = await response.json();
      setData(json);
    } catch (e) {
      setError(e as Error);
    } finally {
      setLoading(false);
    }
  };

  useEffect(() => {
    fetchData();
  }, [url]);

  return { data, loading, error, refetch: fetchData };
}
// hooks/__tests__/useApi.test.ts
import { renderHook, waitFor } from '@testing-library/react-native';
import { useApi } from '../useApi';

// Mock fetch
global.fetch = jest.fn();

describe('useApi', () => {
  beforeEach(() => {
    (fetch as jest.Mock).mockClear();
  });

  it('fetches data successfully', async () => {
    const mockData = { id: 1, name: 'Test' };
    (fetch as jest.Mock).mockResolvedValueOnce({
      ok: true,
      json: async () => mockData,
    });

    const { result } = renderHook(() => useApi('/api/test'));

    expect(result.current.loading).toBe(true);

    await waitFor(() => {
      expect(result.current.loading).toBe(false);
    });

    expect(result.current.data).toEqual(mockData);
    expect(result.current.error).toBeNull();
  });

  it('handles fetch error', async () => {
    (fetch as jest.Mock).mockResolvedValueOnce({
      ok: false,
    });

    const { result } = renderHook(() => useApi('/api/test'));

    await waitFor(() => {
      expect(result.current.loading).toBe(false);
    });

    expect(result.current.data).toBeNull();
    expect(result.current.error).toBeInstanceOf(Error);
  });

  it('handles network error', async () => {
    (fetch as jest.Mock).mockRejectedValueOnce(new Error('Network error'));

    const { result } = renderHook(() => useApi('/api/test'));

    await waitFor(() => {
      expect(result.current.loading).toBe(false);
    });

    expect(result.current.error?.message).toBe('Network error');
  });

  it('refetches data', async () => {
    const mockData1 = { id: 1 };
    const mockData2 = { id: 2 };
    
    (fetch as jest.Mock)
      .mockResolvedValueOnce({ ok: true, json: async () => mockData1 })
      .mockResolvedValueOnce({ ok: true, json: async () => mockData2 });

    const { result } = renderHook(() => useApi('/api/test'));

    await waitFor(() => {
      expect(result.current.data).toEqual(mockData1);
    });

    result.current.refetch();

    await waitFor(() => {
      expect(result.current.data).toEqual(mockData2);
    });

    expect(fetch).toHaveBeenCalledTimes(2);
  });
});

Testing Context

// context/AuthContext.tsx
import { createContext, useContext, useState, ReactNode } from 'react';

interface User {
  id: string;
  email: string;
}

interface AuthContextType {
  user: User | null;
  login: (email: string, password: string) => Promise<void>;
  logout: () => void;
}

const AuthContext = createContext<AuthContextType | undefined>(undefined);

export function AuthProvider({ children }: { children: ReactNode }) {
  const [user, setUser] = useState<User | null>(null);

  const login = async (email: string, password: string) => {
    // Simulate API call
    const response = await fetch('/api/login', {
      method: 'POST',
      body: JSON.stringify({ email, password }),
    });
    const data = await response.json();
    setUser(data.user);
  };

  const logout = () => {
    setUser(null);
  };

  return (
    <AuthContext.Provider value={{ user, login, logout }}>
      {children}
    </AuthContext.Provider>
  );
}

export function useAuth() {
  const context = useContext(AuthContext);
  if (!context) {
    throw new Error('useAuth must be used within AuthProvider');
  }
  return context;
}
// context/__tests__/AuthContext.test.tsx
import { renderHook, act, waitFor } from '@testing-library/react-native';
import { AuthProvider, useAuth } from '../AuthContext';

global.fetch = jest.fn();

describe('AuthContext', () => {
  const wrapper = ({ children }: { children: React.ReactNode }) => (
    <AuthProvider>{children}</AuthProvider>
  );

  beforeEach(() => {
    (fetch as jest.Mock).mockClear();
  });

  it('provides initial null user', () => {
    const { result } = renderHook(() => useAuth(), { wrapper });
    
    expect(result.current.user).toBeNull();
  });

  it('logs in user successfully', async () => {
    const mockUser = { id: '1', email: 'test@example.com' };
    (fetch as jest.Mock).mockResolvedValueOnce({
      json: async () => ({ user: mockUser }),
    });

    const { result } = renderHook(() => useAuth(), { wrapper });

    await act(async () => {
      await result.current.login('test@example.com', 'password');
    });

    expect(result.current.user).toEqual(mockUser);
  });

  it('logs out user', async () => {
    const mockUser = { id: '1', email: 'test@example.com' };
    (fetch as jest.Mock).mockResolvedValueOnce({
      json: async () => ({ user: mockUser }),
    });

    const { result } = renderHook(() => useAuth(), { wrapper });

    await act(async () => {
      await result.current.login('test@example.com', 'password');
    });

    expect(result.current.user).not.toBeNull();

    act(() => {
      result.current.logout();
    });

    expect(result.current.user).toBeNull();
  });

  it('throws error when used outside provider', () => {
    expect(() => {
      renderHook(() => useAuth());
    }).toThrow('useAuth must be used within AuthProvider');
  });
});

Mocking Native Modules

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.

Common Mocks

// __mocks__/react-native-camera.ts
export const RNCamera = {
  Constants: {
    Type: { back: 'back', front: 'front' },
    FlashMode: { on: 'on', off: 'off', auto: 'auto' },
  },
};

export default {
  takePictureAsync: jest.fn().mockResolvedValue({ uri: 'mock-uri' }),
};
// __mocks__/@react-native-async-storage/async-storage.ts
let store: Record<string, string> = {};

export default {
  setItem: jest.fn((key: string, value: string) => {
    store[key] = value;
    return Promise.resolve();
  }),
  getItem: jest.fn((key: string) => {
    return Promise.resolve(store[key] || null);
  }),
  removeItem: jest.fn((key: string) => {
    delete store[key];
    return Promise.resolve();
  }),
  clear: jest.fn(() => {
    store = {};
    return Promise.resolve();
  }),
  getAllKeys: jest.fn(() => {
    return Promise.resolve(Object.keys(store));
  }),
  multiGet: jest.fn((keys: string[]) => {
    return Promise.resolve(keys.map(key => [key, store[key] || null]));
  }),
  multiSet: jest.fn((pairs: [string, string][]) => {
    pairs.forEach(([key, value]) => {
      store[key] = value;
    });
    return Promise.resolve();
  }),
};
// __mocks__/expo-location.ts
export const requestForegroundPermissionsAsync = jest.fn().mockResolvedValue({
  status: 'granted',
});

export const getCurrentPositionAsync = jest.fn().mockResolvedValue({
  coords: {
    latitude: 37.7749,
    longitude: -122.4194,
    altitude: 0,
    accuracy: 5,
    heading: 0,
    speed: 0,
  },
  timestamp: Date.now(),
});

export const watchPositionAsync = jest.fn().mockReturnValue({
  remove: jest.fn(),
});

Testing with Mocked Modules

// screens/__tests__/LocationScreen.test.tsx
import { render, screen, waitFor } from '@testing-library/react-native';
import * as Location from 'expo-location';
import { LocationScreen } from '../LocationScreen';

jest.mock('expo-location');

describe('LocationScreen', () => {
  beforeEach(() => {
    jest.clearAllMocks();
  });

  it('requests location permission on mount', async () => {
    render(<LocationScreen />);

    await waitFor(() => {
      expect(Location.requestForegroundPermissionsAsync).toHaveBeenCalled();
    });
  });

  it('displays location when permission granted', async () => {
    render(<LocationScreen />);

    await waitFor(() => {
      expect(screen.getByText(/37.7749/)).toBeTruthy();
      expect(screen.getByText(/-122.4194/)).toBeTruthy();
    });
  });

  it('shows error when permission denied', async () => {
    (Location.requestForegroundPermissionsAsync as jest.Mock).mockResolvedValueOnce({
      status: 'denied',
    });

    render(<LocationScreen />);

    await waitFor(() => {
      expect(screen.getByText('Location permission denied')).toBeTruthy();
    });
  });
});

Snapshot Testing

// components/__tests__/ProductCard.test.tsx
import { render } from '@testing-library/react-native';
import { ProductCard } from '../ProductCard';

describe('ProductCard', () => {
  const defaultProps = {
    id: '1',
    name: 'Test Product',
    price: 99.99,
    image: 'https://example.com/image.jpg',
    onPress: jest.fn(),
  };

  it('matches snapshot', () => {
    const { toJSON } = render(<ProductCard {...defaultProps} />);
    
    expect(toJSON()).toMatchSnapshot();
  });

  it('matches snapshot when on sale', () => {
    const { toJSON } = render(
      <ProductCard {...defaultProps} salePrice={79.99} />
    );
    
    expect(toJSON()).toMatchSnapshot();
  });

  it('matches snapshot when out of stock', () => {
    const { toJSON } = render(
      <ProductCard {...defaultProps} inStock={false} />
    );
    
    expect(toJSON()).toMatchSnapshot();
  });
});
Snapshot Testing Best Practices:
  • Use sparingly - they can become maintenance burden
  • Keep snapshots small and focused
  • Review snapshot changes carefully in PRs
  • Consider inline snapshots for small components

Testing Utilities

Custom Render Function

// test-utils/render.tsx
import { render, RenderOptions } from '@testing-library/react-native';
import { ReactElement } from 'react';
import { NavigationContainer } from '@react-navigation/native';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { AuthProvider } from '@/context/AuthContext';
import { ThemeProvider } from '@/context/ThemeContext';

const queryClient = new QueryClient({
  defaultOptions: {
    queries: { retry: false },
    mutations: { retry: false },
  },
});

interface CustomRenderOptions extends Omit<RenderOptions, 'wrapper'> {
  initialRoute?: string;
}

function AllProviders({ children }: { children: React.ReactNode }) {
  return (
    <QueryClientProvider client={queryClient}>
      <AuthProvider>
        <ThemeProvider>
          <NavigationContainer>
            {children}
          </NavigationContainer>
        </ThemeProvider>
      </AuthProvider>
    </QueryClientProvider>
  );
}

export function customRender(
  ui: ReactElement,
  options?: CustomRenderOptions
) {
  return render(ui, { wrapper: AllProviders, ...options });
}

// Re-export everything
export * from '@testing-library/react-native';
export { customRender as render };

Test Data Factories

// test-utils/factories.ts
import { faker } from '@faker-js/faker';

export function createUser(overrides = {}) {
  return {
    id: faker.string.uuid(),
    email: faker.internet.email(),
    name: faker.person.fullName(),
    avatar: faker.image.avatar(),
    createdAt: faker.date.past().toISOString(),
    ...overrides,
  };
}

export function createProduct(overrides = {}) {
  return {
    id: faker.string.uuid(),
    name: faker.commerce.productName(),
    description: faker.commerce.productDescription(),
    price: parseFloat(faker.commerce.price()),
    image: faker.image.url(),
    category: faker.commerce.department(),
    inStock: faker.datatype.boolean(),
    rating: faker.number.float({ min: 1, max: 5, fractionDigits: 1 }),
    ...overrides,
  };
}

export function createProducts(count: number, overrides = {}) {
  return Array.from({ length: count }, () => createProduct(overrides));
}

Code Coverage

Running Coverage

# Generate coverage report
npm test -- --coverage

# Watch mode with coverage
npm test -- --coverage --watchAll

# Coverage for specific files
npm test -- --coverage --collectCoverageFrom='src/components/**/*.tsx'

Coverage Configuration

// jest.config.js
module.exports = {
  // ... other config
  collectCoverage: true,
  collectCoverageFrom: [
    'src/**/*.{ts,tsx}',
    '!src/**/*.d.ts',
    '!src/**/*.stories.tsx',
    '!src/**/index.ts',
    '!src/types/**',
  ],
  coverageDirectory: 'coverage',
  coverageReporters: ['text', 'lcov', 'html'],
  coverageThreshold: {
    global: {
      branches: 80,
      functions: 80,
      lines: 80,
      statements: 80,
    },
    // Per-file thresholds
    './src/components/': {
      branches: 90,
      functions: 90,
      lines: 90,
      statements: 90,
    },
  },
};

Choosing the Right Testing Approach

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 TestStrategyToolsROI
Business logic (utils, transforms, validators)Pure unit testsJestVery high — fast, stable, catches regressions
Custom hooks (state logic, data fetching)renderHookRNTL + JestHigh — verifies hook API without component coupling
Interactive components (forms, buttons, modals)Behavioral component testsRNTL + fireEventHigh — catches UX regressions
Presentational components (cards, badges, layout)Snapshot tests (sparingly)Jest snapshotsLow-medium — fragile, high maintenance
Navigation flows (auth, onboarding, deep links)Integration testsRNTL with NavigationContainerMedium — complex setup, but catches routing bugs
Full user journeys (login to checkout)E2E testsDetox or MaestroMedium — slow, flaky, but irreplaceable for confidence
Decision framework for what to test first:
  1. 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).
  2. Add behavioral tests for user-interactive components. Forms, modals, and multi-step flows deserve thorough testing because bugs here directly block users.
  3. 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.
  4. 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 React Query Hooks

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.tsx
import { 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.ts
import { renderHook, waitFor } from '@testing-library/react-native';
import { useUsers } from '../useUsers';
import { createQueryWrapper } from '@/test-utils/queryWrapper';

// Mock the API client
jest.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);
  });
});

Edge Cases in React Native Testing

Testing Animated Components

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 imports
jest.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 itself
it('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();
  });
});

Testing Components with Timers

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 out
it('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 waitFor
it('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();
});

Testing Platform-Specific Behavior

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.
describe('PlatformButton', () => {
  afterEach(() => {
    jest.resetModules();
  });

  it('renders Material style on Android', () => {
    jest.doMock('react-native/Libraries/Utilities/Platform', () => ({
      OS: 'android',
      select: (obj: any) => obj.android,
    }));
    
    const { PlatformButton } = require('../PlatformButton');
    const { getByTestId } = render(<PlatformButton title="Click" />);
    
    // Assert Android-specific rendering
    expect(getByTestId('material-ripple')).toBeTruthy();
  });

  it('renders Cupertino style on iOS', () => {
    jest.doMock('react-native/Libraries/Utilities/Platform', () => ({
      OS: 'ios',
      select: (obj: any) => obj.ios,
    }));
    
    const { PlatformButton } = require('../PlatformButton');
    const { getByTestId } = render(<PlatformButton title="Click" />);
    
    // Assert iOS-specific rendering
    expect(getByTestId('ios-highlight')).toBeTruthy();
  });
});

Best Practices

Test Behavior, Not Implementation

Focus on what the component does, not how it does it

Use Accessible Queries

Prefer getByRole, getByLabelText over getByTestId

Avoid Testing Implementation Details

Don’t test internal state or private methods

Keep Tests Independent

Each test should be able to run in isolation

Query Priority

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:
PriorityQueryWhen to Use
1 (best)getByRoleButtons, links, inputs — anything with a semantic role
2getByLabelTextForm inputs associated with a label
3getByPlaceholderTextInputs with placeholder text (weaker than label)
4getByTextNon-interactive elements identified by visible text
5getByDisplayValueInputs with a current value
6 (last resort)getByTestIdWhen no accessible query applies — custom native views, complex layouts
// Preferred (most accessible)
screen.getByRole('button', { name: 'Submit' });
screen.getByLabelText('Email');
screen.getByPlaceholderText('Enter email');
screen.getByText('Welcome');

// Less preferred (implementation detail)
screen.getByTestId('submit-button');

Coverage Targets That Actually Help

A blanket “80% coverage” mandate is worse than targeted coverage requirements. Here is a more useful breakdown:
Code CategoryTargetRationale
Business logic / utils90%+Pure functions, high ROI, no excuse to skip
Custom hooks80%+State transitions and side effects are bug-prone
Form components80%+Validation logic and submission flows affect users directly
Presentational components40-60%A basic render test is enough; deep testing is low ROI
Navigation / routingIntegration test per flowCoverage % is misleading — test the user journey
Native module wrappersMock boundary onlyYou cannot unit-test native code in Jest; verify the JS interface

Next Steps

Module 28: Integration Testing

Learn to test component interactions and navigation flows