Skip to main content
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

npx expo install jest-expo jest @testing-library/react-native @testing-library/jest-native

Configure Jest

1

Create Jest config

jest.config.js
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',
  ],
};
2

Create setup file

jest-setup.js
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(),
};
3

Add test script

package.json
{
  "scripts": {
    "test": "jest",
    "test:watch": "jest --watch",
    "test:coverage": "jest --coverage"
  }
}

Testing Components

Basic Component Test

app/components/__tests__/Button.test.tsx
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

app/components/__tests__/Counter.test.tsx
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

app/components/__tests__/UserProfile.test.tsx
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

app/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(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

app/(tabs)/__tests__/layout.test.tsx
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

app/__tests__/navigation.test.tsx
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

__tests__/camera.test.tsx
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

modules/__tests__/MyModule.test.ts
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

app/components/__tests__/Card.test.tsx
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

# 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

# Run tests with coverage
npm test -- --coverage

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

# Open report
open coverage/index.html

Coverage Configuration

jest.config.js
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

.github/workflows/test.yml
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

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

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

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

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

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

// Or globally
jest.setTimeout(10000);

Mock Not Working

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

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

E2E Testing

Test full user workflows

Testing Router

Test Expo Router navigation

CI/CD

Automate testing in CI

Error Handling

Test error scenarios