> ## Documentation Index
> Fetch the complete documentation index at: https://mintlify.com/expo/expo/llms.txt
> Use this file to discover all available pages before exploring further.

# Unit Testing

> Set up Jest and write unit tests for your Expo app components and modules.

Unit testing helps catch bugs early and ensures your components work as expected. This guide covers Jest configuration, testing React components, and testing Expo modules.

## Setup

### Install Dependencies

```bash theme={null}
npx expo install jest-expo jest @testing-library/react-native @testing-library/jest-native
```

### Configure Jest

<Steps>
  <Step title="Create Jest config">
    ```javascript title="jest.config.js" theme={null}
    module.exports = {
      preset: 'jest-expo',
      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)'
      ],
      setupFilesAfterEnv: ['<rootDir>/jest-setup.js'],
      collectCoverageFrom: [
        'app/**/*.{js,jsx,ts,tsx}',
        '!app/**/*.test.{js,jsx,ts,tsx}',
        '!app/_layout.tsx',
        '!app/**/index.tsx',
      ],
    };
    ```
  </Step>

  <Step title="Create setup file">
    ```javascript title="jest-setup.js" theme={null}
    import '@testing-library/jest-native/extend-expect';

    // Mock expo-router
    jest.mock('expo-router', () => ({
      ...jest.requireActual('expo-router'),
      useRouter: () => ({
        push: jest.fn(),
        replace: jest.fn(),
        back: jest.fn(),
      }),
      useLocalSearchParams: () => ({}),
      useGlobalSearchParams: () => ({}),
    }));

    // Mock expo modules
    jest.mock('expo-font');
    jest.mock('expo-asset');
    jest.mock('expo-constants', () => ({
      expoConfig: {},
      systemFonts: [],
    }));

    // Silence console errors in tests
    global.console = {
      ...console,
      error: jest.fn(),
      warn: jest.fn(),
    };
    ```
  </Step>

  <Step title="Add test script">
    ```json title="package.json" theme={null}
    {
      "scripts": {
        "test": "jest",
        "test:watch": "jest --watch",
        "test:coverage": "jest --coverage"
      }
    }
    ```
  </Step>
</Steps>

## Testing Components

### Basic Component Test

```typescript title="app/components/__tests__/Button.test.tsx" theme={null}
import { render, fireEvent, screen } from '@testing-library/react-native';
import { Button } from '../Button';

describe('Button', () => {
  it('renders correctly', () => {
    render(<Button title="Press me" />);
    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('is disabled when disabled prop is true', () => {
    render(<Button title="Press me" disabled />);
    
    const button = screen.getByText('Press me');
    expect(button).toBeDisabled();
  });
});
```

### Component with State

```typescript title="app/components/__tests__/Counter.test.tsx" theme={null}
import { render, fireEvent, screen, waitFor } from '@testing-library/react-native';
import { Counter } from '../Counter';

describe('Counter', () => {
  it('increments counter', () => {
    render(<Counter initialCount={0} />);
    
    const increment = screen.getByText('Increment');
    const counter = screen.getByTestId('counter-value');
    
    expect(counter).toHaveTextContent('0');
    
    fireEvent.press(increment);
    expect(counter).toHaveTextContent('1');
    
    fireEvent.press(increment);
    expect(counter).toHaveTextContent('2');
  });

  it('decrements counter', () => {
    render(<Counter initialCount={5} />);
    
    const decrement = screen.getByText('Decrement');
    const counter = screen.getByTestId('counter-value');
    
    expect(counter).toHaveTextContent('5');
    
    fireEvent.press(decrement);
    expect(counter).toHaveTextContent('4');
  });

  it('resets counter', () => {
    render(<Counter initialCount={10} />);
    
    const reset = screen.getByText('Reset');
    const counter = screen.getByTestId('counter-value');
    
    fireEvent.press(reset);
    expect(counter).toHaveTextContent('0');
  });
});
```

### Async Components

```typescript title="app/components/__tests__/UserProfile.test.tsx" theme={null}
import { render, screen, waitFor } from '@testing-library/react-native';
import { UserProfile } from '../UserProfile';

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

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

  it('shows loading state', () => {
    (fetch as jest.Mock).mockImplementation(() => 
      new Promise(() => {}) // Never resolves
    );
    
    render(<UserProfile userId="123" />);
    expect(screen.getByText('Loading...')).toBeTruthy();
  });

  it('displays user data after loading', async () => {
    const mockUser = {
      id: '123',
      name: 'John Doe',
      email: 'john@example.com',
    };
    
    (fetch as jest.Mock).mockResolvedValueOnce({
      ok: true,
      json: async () => mockUser,
    });
    
    render(<UserProfile userId="123" />);
    
    await waitFor(() => {
      expect(screen.getByText('John Doe')).toBeTruthy();
      expect(screen.getByText('john@example.com')).toBeTruthy();
    });
  });

  it('displays error message on failure', async () => {
    (fetch as jest.Mock).mockRejectedValueOnce(
      new Error('Network error')
    );
    
    render(<UserProfile userId="123" />);
    
    await waitFor(() => {
      expect(screen.getByText(/error/i)).toBeTruthy();
    });
  });
});
```

### Testing Hooks

```typescript title="app/hooks/__tests__/useCounter.test.ts" theme={null}
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(0));
    
    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 Expo Router

Test navigation and routing with Expo Router's testing utilities.

### Setup Router Tests

```typescript title="app/(tabs)/__tests__/layout.test.tsx" theme={null}
import { renderRouter, screen } from 'expo-router/testing-library';
import { Text } from 'react-native';
import { Tabs } from 'expo-router';

describe('Tab Layout', () => {
  it('renders home tab by default', () => {
    renderRouter({
      _layout: () => <Tabs />,
      index: () => <Text testID="home">Home</Text>,
      profile: () => <Text testID="profile">Profile</Text>,
    });
    
    expect(screen.getByTestId('home')).toBeVisible();
  });

  it('navigates between tabs', () => {
    const { router } = renderRouter({
      _layout: () => <Tabs />,
      index: () => <Text testID="home">Home</Text>,
      profile: () => <Text testID="profile">Profile</Text>,
    });
    
    expect(screen.getByTestId('home')).toBeVisible();
    
    router.push('/profile');
    expect(screen.getByTestId('profile')).toBeVisible();
  });
});
```

### Testing Navigation

```typescript title="app/__tests__/navigation.test.tsx" theme={null}
import { renderRouter, screen } from 'expo-router/testing-library';
import { router } from 'expo-router';
import { act } from '@testing-library/react-native';
import { Text } from 'react-native';

describe('Navigation', () => {
  it('can navigate forward and back', () => {
    renderRouter({
      index: () => <Text testID="index">Index</Text>,
      'profile/[id]': () => <Text testID="profile">Profile</Text>,
    });
    
    expect(screen.getByTestId('index')).toBeVisible();
    
    act(() => router.push('/profile/123'));
    expect(screen.getByTestId('profile')).toBeVisible();
    
    act(() => router.back());
    expect(screen.getByTestId('index')).toBeVisible();
  });

  it('reads search params', () => {
    renderRouter(
      {
        index: () => {
          const { id, name } = useLocalSearchParams();
          return (
            <Text testID="params">
              {id} - {name}
            </Text>
          );
        },
      },
      {
        initialUrl: '/?id=123&name=John',
      }
    );
    
    expect(screen.getByTestId('params')).toHaveTextContent('123 - John');
  });
});
```

## Testing Native Modules

### Mock Native Modules

```typescript title="__tests__/camera.test.tsx" theme={null}
import { render, fireEvent, waitFor } from '@testing-library/react-native';
import * as Camera from 'expo-camera';
import { CameraScreen } from '../CameraScreen';

// Mock the camera module
jest.mock('expo-camera', () => ({
  Camera: 'Camera',
  CameraType: { back: 'back', front: 'front' },
  requestCameraPermissionsAsync: jest.fn(),
}));

describe('CameraScreen', () => {
  it('requests permissions on mount', async () => {
    const mockRequest = jest.fn().mockResolvedValue({ 
      status: 'granted' 
    });
    
    (Camera.requestCameraPermissionsAsync as jest.Mock) = mockRequest;
    
    render(<CameraScreen />);
    
    await waitFor(() => {
      expect(mockRequest).toHaveBeenCalled();
    });
  });

  it('shows permission denied message', async () => {
    (Camera.requestCameraPermissionsAsync as jest.Mock)
      .mockResolvedValue({ status: 'denied' });
    
    render(<CameraScreen />);
    
    await waitFor(() => {
      expect(screen.getByText(/permission denied/i)).toBeTruthy();
    });
  });
});
```

### Testing Expo Modules

```typescript title="modules/__tests__/MyModule.test.ts" theme={null}
import { NativeModules } from 'react-native';
import MyModule from '../MyModule';

// Mock native module
jest.mock('react-native', () => ({
  NativeModules: {
    ExpoMyModule: {
      doSomething: jest.fn(),
      getValue: jest.fn(),
    },
  },
}));

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

  it('calls native method', () => {
    MyModule.doSomething('test');
    
    expect(NativeModules.ExpoMyModule.doSomething)
      .toHaveBeenCalledWith('test');
  });

  it('returns value from native', async () => {
    NativeModules.ExpoMyModule.getValue
      .mockResolvedValue('result');
    
    const result = await MyModule.getValue();
    expect(result).toBe('result');
  });
});
```

## Snapshot Testing

### Component Snapshots

```typescript title="app/components/__tests__/Card.test.tsx" theme={null}
import { render } from '@testing-library/react-native';
import { Card } from '../Card';

describe('Card', () => {
  it('matches snapshot', () => {
    const tree = render(
      <Card title="Test Card" content="Test content" />
    ).toJSON();
    
    expect(tree).toMatchSnapshot();
  });

  it('matches snapshot with different props', () => {
    const tree = render(
      <Card 
        title="Different Card" 
        content="Different content"
        variant="outlined"
      />
    ).toJSON();
    
    expect(tree).toMatchSnapshot();
  });
});
```

### Updating Snapshots

```bash theme={null}
# Update all snapshots
jest --updateSnapshot

# Update specific test file
jest Card.test.tsx --updateSnapshot

# Interactive mode
jest --watch
# Press 'u' to update failing snapshots
```

## Test Coverage

### Generate Coverage Report

```bash theme={null}
# Run tests with coverage
npm test -- --coverage

# Generate HTML report
npm test -- --coverage --coverageReporters=html

# Open report
open coverage/index.html
```

### Coverage Configuration

```javascript title="jest.config.js" theme={null}
module.exports = {
  preset: 'jest-expo',
  collectCoverageFrom: [
    'app/**/*.{js,jsx,ts,tsx}',
    '!app/**/*.test.{js,jsx,ts,tsx}',
    '!app/**/__tests__/**',
    '!app/_layout.tsx',
  ],
  coverageThresholds: {
    global: {
      branches: 80,
      functions: 80,
      lines: 80,
      statements: 80,
    },
  },
};
```

## CI/CD Integration

### GitHub Actions

```yaml title=".github/workflows/test.yml" theme={null}
name: Test

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    
    steps:
      - uses: actions/checkout@v3
      
      - name: Setup Node
        uses: actions/setup-node@v3
        with:
          node-version: '18'
          cache: 'npm'
      
      - name: Install dependencies
        run: npm ci
      
      - name: Run tests
        run: npm test -- --coverage
      
      - name: Upload coverage
        uses: codecov/codecov-action@v3
        with:
          files: ./coverage/coverage-final.json
```

## Best Practices

### 1. Use Test IDs

```typescript theme={null}
// Component
<View testID="user-card">
  <Text testID="user-name">{user.name}</Text>
  <Text testID="user-email">{user.email}</Text>
</View>

// Test
const card = screen.getByTestId('user-card');
const name = screen.getByTestId('user-name');
```

### 2. Test User Behavior, Not Implementation

```typescript theme={null}
// Bad: Testing implementation details
it('sets loading state to true', () => {
  const { result } = renderHook(() => useFetch('/api'));
  expect(result.current.loading).toBe(true);
});

// Good: Testing user-visible behavior
it('shows loading indicator', () => {
  render(<DataComponent />);
  expect(screen.getByText('Loading...')).toBeTruthy();
});
```

### 3. Arrange-Act-Assert Pattern

```typescript theme={null}
it('increments counter when button is pressed', () => {
  // Arrange
  render(<Counter initialCount={0} />);
  const button = screen.getByText('Increment');
  
  // Act
  fireEvent.press(button);
  
  // Assert
  expect(screen.getByTestId('count')).toHaveTextContent('1');
});
```

### 4. Clean Up After Tests

```typescript theme={null}
describe('Component with side effects', () => {
  let unsubscribe: () => void;
  
  afterEach(() => {
    unsubscribe?.();
    jest.clearAllMocks();
  });
  
  it('subscribes to events', () => {
    const { result } = renderHook(() => useEventSubscription());
    unsubscribe = result.current.unsubscribe;
    // Test...
  });
});
```

## Troubleshooting

### Tests Timeout

```typescript theme={null}
// Increase timeout for slow tests
it('loads data', async () => {
  // ...
}, 10000); // 10 second timeout

// Or globally
jest.setTimeout(10000);
```

### Mock Not Working

```typescript theme={null}
// Ensure mock is before import
jest.mock('expo-camera');
import * as Camera from 'expo-camera';

// Or use doMock for dynamic mocks
jest.doMock('expo-camera', () => ({ /* mock */ }));
```

### Act Warnings

```typescript theme={null}
// Wrap state updates in act()
import { act } from '@testing-library/react-native';

act(() => {
  fireEvent.press(button);
});

// For async operations
await act(async () => {
  await fetchData();
});
```

## Next Steps

<CardGroup cols={2}>
  <Card title="E2E Testing" icon="robot" href="/development/e2e-testing">
    Test full user workflows
  </Card>

  <Card title="Testing Router" icon="route" href="/development/testing-router">
    Test Expo Router navigation
  </Card>

  <Card title="CI/CD" icon="arrows-rotate" href="/development/e2e-testing#cicd-integration">
    Automate testing in CI
  </Card>

  <Card title="Error Handling" icon="triangle-exclamation" href="/development/errors">
    Test error scenarios
  </Card>
</CardGroup>
