> ## 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.

# 27. Unit Testing

> Testing React Native components and logic with Jest and React Native Testing Library

<Frame>
  <img src="https://mintlify.s3.us-west-1.amazonaws.com/devweeekends/images/courses/react-native-crash-course/unit-testing.svg" alt="Unit Testing" />
</Frame>

## Module Overview

<Info>
  **Estimated Time**: 4 hours | **Difficulty**: Intermediate | **Prerequisites**: Core React Native concepts
</Info>

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:

```json theme={null}
// 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.

```javascript theme={null}
// 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

```typescript theme={null}
// 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:

```bash theme={null}
npm install --save-dev @testing-library/react-native @testing-library/jest-native
```

### Basic Component Testing

```tsx theme={null}
// 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',
  },
});
```

```tsx theme={null}
// 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

```tsx theme={null}
// 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

```tsx theme={null}
// 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 };
}
```

```tsx theme={null}
// 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

```tsx theme={null}
// 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 };
}
```

```tsx theme={null}
// 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

```tsx theme={null}
// 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;
}
```

```tsx theme={null}
// 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.

<Tip>
  **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.
</Tip>

### Common Mocks

```typescript theme={null}
// __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' }),
};
```

```typescript theme={null}
// __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();
  }),
};
```

```typescript theme={null}
// __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

```tsx theme={null}
// 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

```tsx theme={null}
// 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();
  });
});
```

<Warning>
  **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
</Warning>

***

## Testing Utilities

### Custom Render Function

```tsx theme={null}
// 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

```tsx theme={null}
// 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

```bash theme={null}
# 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

```javascript theme={null}
// 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 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:**

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).

```tsx theme={null}
// 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>
  );
}
```

```tsx theme={null}
// 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.

```tsx theme={null}
// 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.

```tsx theme={null}
// 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.

```tsx theme={null}
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

<CardGroup cols={2}>
  <Card title="Test Behavior, Not Implementation" icon="eye">
    Focus on what the component does, not how it does it
  </Card>

  <Card title="Use Accessible Queries" icon="universal-access">
    Prefer getByRole, getByLabelText over getByTestId
  </Card>

  <Card title="Avoid Testing Implementation Details" icon="ban">
    Don't test internal state or private methods
  </Card>

  <Card title="Keep Tests Independent" icon="puzzle-piece">
    Each test should be able to run in isolation
  </Card>
</CardGroup>

### 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:

| 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 |

```tsx theme={null}
// 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 Category                 | Target                    | Rationale                                                         |
| ----------------------------- | ------------------------- | ----------------------------------------------------------------- |
| **Business logic / utils**    | 90%+                      | Pure functions, high ROI, no excuse to skip                       |
| **Custom hooks**              | 80%+                      | State transitions and side effects are bug-prone                  |
| **Form components**           | 80%+                      | Validation logic and submission flows affect users directly       |
| **Presentational components** | 40-60%                    | A basic render test is enough; deep testing is low ROI            |
| **Navigation / routing**      | Integration test per flow | Coverage % is misleading -- test the user journey                 |
| **Native module wrappers**    | Mock boundary only        | You cannot unit-test native code in Jest; verify the JS interface |

***

## Next Steps

<Card title="Module 28: Integration Testing" icon="arrow-right" href="/courses/react-native-crash-course/28-integration-testing">
  Learn to test component interactions and navigation flows
</Card>
