Skip to content

Latest commit

 

History

History
794 lines (608 loc) · 21.7 KB

File metadata and controls

794 lines (608 loc) · 21.7 KB

Testing

Navigation: ← Dependency Injection | Performance →


Table of Contents


Overview

Testing is critical for maintaining quality in React Native applications. The ecosystem provides tools for every level of the testing pyramid: Jest for unit tests, React Native Testing Library (RNTL) for component tests, and Detox or Maestro for end-to-end tests. Understanding what to test, how to mock native modules, and when to avoid snapshot testing separates senior developers from juniors in interviews.

This guide covers testing strategies, tooling, and patterns for React Native applications.


Theory

Testing Pyramid for React Native

        ╱ E2E (Detox/Maestro) ╲        ← Few, slow, high confidence
       ╱ Integration Tests       ╲      ← Moderate count
      ╱ Component Tests (RNTL)    ╲     ← Many, fast
     ╱ Unit Tests (Jest)            ╲   ← Most, fastest
Level Tool Speed Scope When to Use
Unit Jest ~ms Functions, hooks, services Business logic, utilities
Component RNTL ~100ms UI components User interactions, rendering
Integration Jest + RNTL ~500ms Feature flows Multi-component workflows
E2E Detox/Maestro ~minutes Full app Critical user journeys

Jest Configuration

Jest is the default test runner for React Native. Key configuration:

  • preset: 'react-native' — RN-specific transforms and mocks
  • setupFilesAfterSetup — Global mocks and test utilities
  • transformIgnorePatterns — Allow transforming specific node_modules
  • moduleNameMapper — Mock assets and path aliases

React Native Testing Library

RNTL follows Testing Library principles:

  • Test behavior, not implementation
  • Query by accessibility roles, labels, and text
  • Simulate user interactions (press, type, scroll)
  • Avoid testing internal state or component structure

Mocking Strategies

React Native requires mocking:

  • Native modulesNativeModules, platform APIs
  • Navigation — React Navigation container and hooks
  • Async storage — MMKV, AsyncStorage, Keychain
  • Network — fetch, Axios, React Query
  • Animations — Reanimated, Animated API

Detox E2E Testing

Detox runs tests on real simulators/emulators with gray-box testing — it synchronizes with the app's idle state before interacting. Requires native build configuration.

Maestro E2E Testing

Maestro uses YAML-based test flows. Simpler setup than Detox, no native code changes required. Runs on simulators/emulators and cloud devices.

Snapshot Testing Pitfalls

Snapshot tests capture rendered output and compare against stored snapshots. Useful for regression detection but prone to false positives and maintenance burden if overused.


Code Examples

Jest Configuration

// jest.config.js
module.exports = {
  preset: 'react-native',
  setupFilesAfterSetup: ['./jest.setup.js'],
  transformIgnorePatterns: [
    'node_modules/(?!(react-native|@react-native|@react-navigation|react-native-reanimated|@tanstack/react-query)/)',
  ],
  moduleNameMapper: {
    '^@/(.*)$': '<rootDir>/src/$1',
    '\\.(jpg|jpeg|png|gif|svg)$': '<rootDir>/__mocks__/fileMock.js',
  },
  collectCoverageFrom: [
    'src/**/*.{ts,tsx}',
    '!src/**/*.d.ts',
    '!src/**/index.ts',
  ],
  coverageThreshold: {
    global: { branches: 70, functions: 70, lines: 70, statements: 70 },
  },
};

Jest Setup with Global Mocks

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

jest.mock('react-native-reanimated', () =>
  require('react-native-reanimated/mock'),
);

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

jest.mock('react-native/Libraries/Animated/NativeAnimatedHelper');

jest.mock('@react-navigation/native', () => ({
  ...jest.requireActual('@react-navigation/native'),
  useNavigation: () => ({
    navigate: jest.fn(),
    goBack: jest.fn(),
    reset: jest.fn(),
  }),
  useRoute: () => ({ params: {} }),
}));

global.__reanimatedWorkletInit = jest.fn();

Unit Testing a Custom Hook

// __tests__/useCounter.test.ts
import { renderHook, act } from '@testing-library/react-native';
import { useCounter } from '../hooks/useCounter';

describe('useCounter', () => {
  it('should initialize with default value', () => {
    const { result } = renderHook(() => useCounter());
    expect(result.current.count).toBe(0);
  });

  it('should increment count', () => {
    const { result } = renderHook(() => useCounter());

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

    expect(result.current.count).toBe(1);
  });

  it('should decrement count', () => {
    const { result } = renderHook(() => useCounter(5));

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

    expect(result.current.count).toBe(4);
  });

  it('should reset to initial value', () => {
    const { result } = renderHook(() => useCounter(10));

    act(() => {
      result.current.increment();
      result.current.increment();
      result.current.reset();
    });

    expect(result.current.count).toBe(10);
  });
});

Component Testing with RNTL

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

const mockLogin = jest.fn();

jest.mock('../hooks/useAuth', () => ({
  useAuth: () => ({
    login: mockLogin,
    isLoading: false,
    error: null,
  }),
}));

describe('LoginScreen', () => {
  beforeEach(() => {
    mockLogin.mockClear();
  });

  it('renders email and password inputs', () => {
    render(<LoginScreen />);

    expect(screen.getByLabelText('Email')).toBeTruthy();
    expect(screen.getByLabelText('Password')).toBeTruthy();
    expect(screen.getByRole('button', { name: 'Sign In' })).toBeTruthy();
  });

  it('shows validation error for empty email', async () => {
    render(<LoginScreen />);

    fireEvent.press(screen.getByRole('button', { name: 'Sign In' }));

    await waitFor(() => {
      expect(screen.getByText('Email is required')).toBeTruthy();
    });
    expect(mockLogin).not.toHaveBeenCalled();
  });

  it('calls login with credentials on valid submit', async () => {
    mockLogin.mockResolvedValue(undefined);
    render(<LoginScreen />);

    fireEvent.changeText(screen.getByLabelText('Email'), 'user@example.com');
    fireEvent.changeText(screen.getByLabelText('Password'), 'password123');
    fireEvent.press(screen.getByRole('button', { name: 'Sign In' }));

    await waitFor(() => {
      expect(mockLogin).toHaveBeenCalledWith({
        email: 'user@example.com',
        password: 'password123',
      });
    });
  });

  it('disables submit button while loading', () => {
    jest.doMock('../hooks/useAuth', () => ({
      useAuth: () => ({
        login: mockLogin,
        isLoading: true,
        error: null,
      }),
    }));

    const { LoginScreen: LoadingLoginScreen } = require('../screens/LoginScreen');
    render(<LoadingLoginScreen />);
    expect(screen.getByRole('button', { name: 'Sign In' })).toBeDisabled();
  });
});

Mocking React Query in Tests

// __tests__/testUtils.tsx
import React from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { render, RenderOptions } from '@testing-library/react-native';

function createTestQueryClient(): QueryClient {
  return new QueryClient({
    defaultOptions: {
      queries: { retry: false, gcTime: 0 },
      mutations: { retry: false },
    },
  });
}

export function renderWithQueryClient(
  ui: React.ReactElement,
  options?: RenderOptions,
) {
  const queryClient = createTestQueryClient();
  return {
    ...render(
      <QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>,
      options,
    ),
    queryClient,
  };
}

// Usage in test
it('displays products from API', async () => {
  jest.spyOn(apiClient, 'get').mockResolvedValue({ data: mockProducts });

  renderWithQueryClient(<ProductListScreen />);

  await waitFor(() => {
    expect(screen.getByText('Product 1')).toBeTruthy();
  });
});

Detox E2E Test

// e2e/login.test.ts
describe('Login Flow', () => {
  beforeAll(async () => {
    await device.launchApp({ newInstance: true });
  });

  beforeEach(async () => {
    await device.reloadReactNative();
  });

  it('should login with valid credentials', async () => {
    await element(by.id('email-input')).typeText('user@example.com');
    await element(by.id('password-input')).typeText('password123');
    await element(by.id('login-button')).tap();

    await waitFor(element(by.id('home-screen')))
      .toBeVisible()
      .withTimeout(5000);

    await expect(element(by.text('Welcome back'))).toBeVisible();
  });

  it('should show error for invalid credentials', async () => {
    await element(by.id('email-input')).typeText('wrong@example.com');
    await element(by.id('password-input')).typeText('wrongpassword');
    await element(by.id('login-button')).tap();

    await expect(element(by.text('Invalid credentials'))).toBeVisible();
  });
});

Maestro E2E Test Flow

# e2e/login-flow.yaml
appId: com.myapp
---
- launchApp
- tapOn:
    id: "email-input"
- inputText: "user@example.com"
- tapOn:
    id: "password-input"
- inputText: "password123"
- tapOn:
    id: "login-button"
- assertVisible:
    id: "home-screen"
- assertVisible: "Welcome back"
# e2e/checkout-flow.yaml
appId: com.myapp
---
- launchApp
- runFlow: login-flow.yaml
- tapOn: "Products"
- tapOn:
    id: "product-card-1"
- tapOn: "Add to Cart"
- tapOn:
    id: "cart-icon"
- assertVisible: "1 item"
- tapOn: "Checkout"
- assertVisible: "Order Summary"

Interview Questions & Answers

Q1: What is the testing pyramid, and how does it apply to React Native?

Answer:

The testing pyramid guides the ratio and scope of tests:

         /  E2E  \          5-10% — Critical user journeys
        / Integr. \         15-20% — Feature workflows
       / Component \        30-35% — UI behavior
      /    Unit     \       40-50% — Business logic

React Native mapping:

Level Tool Example
Unit Jest formatCurrency(1234.5) → "$1,234.50"
Component RNTL Login form validates email, calls onSubmit
Integration Jest + RNTL Auth flow: login → fetch profile → display
E2E Detox/Maestro Full checkout flow on simulator

Why this ratio:

  • Unit tests are fast (~ms), cheap to maintain, catch logic bugs
  • Component tests verify UI behavior without device overhead
  • E2E tests are slow (~minutes), flaky, expensive — reserve for critical paths

React Native specifics:

  • Mock native modules at unit/component level
  • E2E tests run on simulators/emulators (not JSDOM)
  • Bridge/native module behavior only testable at E2E or integration level
  • Use MSW (Mock Service Worker) for API mocking at component level

Q2: How do you configure Jest for a React Native project?

Answer:

Basic configuration:

module.exports = {
  preset: 'react-native',
  setupFilesAfterSetup: ['./jest.setup.js'],
  transformIgnorePatterns: [
    'node_modules/(?!(react-native|@react-native|@react-navigation)/)',
  ],
};

Key configuration areas:

1. Transform ignore patterns — By default, Jest ignores node_modules. RN packages ship untranspiled JSX/TS and must be transformed:

transformIgnorePatterns: [
  'node_modules/(?!(react-native|@react-native|react-native-reanimated|@react-navigation|@tanstack)/)',
]

2. Module name mapper — Handle path aliases and asset imports:

moduleNameMapper: {
  '^@/(.*)$': '<rootDir>/src/$1',
  '\\.(png|svg)$': '<rootDir>/__mocks__/fileMock.js',
}

3. Setup files — Global mocks for native modules:

// jest.setup.js
jest.mock('react-native-reanimated', () =>
  require('react-native-reanimated/mock'),
);

4. Coverage thresholds — Enforce minimum coverage:

coverageThreshold: {
  global: { branches: 70, lines: 70 },
}

Running tests:

npx jest                          # Run all tests
npx jest --watch                  # Watch mode
npx jest --coverage               # With coverage report
npx jest src/features/auth        # Specific directory
npx jest -t "should login"        # By test name

Q3: What are React Native Testing Library best practices?

Answer:

RNTL follows the principle: "The more your tests resemble the way your software is used, the more confidence they can give you."

Query priority (most to least preferred):

  1. getByRole('button', { name: 'Submit' }) — Accessibility role
  2. getByLabelText('Email') — Form labels
  3. getByPlaceholderText('Enter email') — Placeholder text
  4. getByText('Welcome') — Visible text
  5. getByDisplayValue('user@example.com') — Input values
  6. getByTestId('submit-btn') — Last resort — test IDs

Best practices:

// Good — query by role (accessible)
fireEvent.press(screen.getByRole('button', { name: 'Sign In' }));

// Avoid — query by test ID when accessible query exists
fireEvent.press(screen.getByTestId('login-button'));

// Good — wait for async updates
await waitFor(() => {
  expect(screen.getByText('Success')).toBeTruthy();
});

// Avoid — testing implementation details
expect(component.state.isLoading).toBe(false);

User interaction simulation:

fireEvent.press(button);                    // Tap
fireEvent.changeText(input, 'new text');     // Type
fireEvent(input, 'submitEditing');          // Enter key
fireEvent.scroll(flatList, { nativeEvent: { contentOffset: { y: 100 } } });

Async patterns:

  • Use waitFor for state updates after interactions
  • Use findBy* queries (waitFor + getBy combined)
  • Use act() when testing hooks directly with renderHook

Q4: How do you mock native modules and external dependencies?

Answer:

1. Native Modules:

jest.mock('react-native/Libraries/Animated/NativeAnimatedHelper');

jest.mock('react-native/Libraries/Utilities/Platform', () => ({
  OS: 'ios',
  select: jest.fn((obj) => obj.ios),
}));

2. Navigation:

const mockNavigate = jest.fn();
jest.mock('@react-navigation/native', () => ({
  useNavigation: () => ({ navigate: mockNavigate, goBack: jest.fn() }),
  useRoute: () => ({ params: { userId: '123' } }),
}));

3. Async Storage / MMKV:

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

4. Network (MSW — preferred):

import { setupServer } from 'msw/node';
import { http, HttpResponse } from 'msw';

const server = setupServer(
  http.get('/api/users/1', () => {
    return HttpResponse.json({ id: '1', name: 'Test User' });
  }),
);

beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

5. Reanimated:

jest.mock('react-native-reanimated', () =>
  require('react-native-reanimated/mock'),
);

6. Manual mock files (__mocks__/):

__mocks__/
├── react-native-keychain.ts
├── @react-native-community/netinfo.ts
└── react-native-permissions.ts

Key principle: Mock at the boundary (native modules, network, navigation), not internal functions. Test your code's behavior given external inputs/outputs.


Q5: Compare Detox and Maestro for E2E testing.

Answer:

Feature Detox Maestro
Test format JavaScript/TypeScript YAML flows
Setup complexity High (native config) Low (CLI install)
Synchronization Gray-box (waits for idle) Built-in smart waits
Platform support iOS + Android iOS + Android
Cloud CI Detox Cloud (paid) Maestro Cloud
Debugging Screenshots, logs Interactive studio
Learning curve Steep Gentle
Community Established (Wix) Growing (Mobile.dev)
React Native integration Deep (designed for RN) Framework-agnostic

Detox advantages:

  • Designed specifically for React Native
  • Automatic synchronization with JS thread
  • Full JavaScript — reuse helpers, fixtures, factories
  • Mature CI integration

Maestro advantages:

  • Zero native code changes
  • YAML flows readable by non-developers (QA team)
  • Faster initial setup
  • Built-in cloud device farm
  • Flow composition (runFlow: login.yaml)

When to choose Detox:

  • RN-only app with complex interactions
  • Team comfortable with JS test code
  • Need deep RN integration (bridge sync)

When to choose Maestro:

  • Quick E2E setup needed
  • QA team writes tests (YAML)
  • Multi-platform (RN + native screens)
  • Simpler test flows (login, navigation, forms)

Interview tip: Many teams use both — Maestro for smoke tests and QA-driven flows, Detox for developer-written complex scenarios.


Q6: What are the pitfalls of snapshot testing?

Answer:

Snapshot tests render a component and compare output against a stored snapshot file. Common pitfalls:

1. False positives from cosmetic changes:

// Any style change breaks ALL snapshots containing this component
<Text style={{ fontSize: 16 }}>Hello</Text>  // Change to 17  snapshot fail

2. Large, unreadable snapshots:

// 500-line snapshot for a complex component — nobody reviews the diff
expect(tree.toJSON()).toMatchSnapshot();

3. Snapshot fatigue — developers blindly update:

jest -u  # "Update all snapshots" — masks real regressions

4. Testing implementation, not behavior: Snapshots capture the entire render tree including wrapper Views, styles, and internal structure — not what the user sees or does.

5. Platform-specific snapshots: iOS and Android render different native trees — need separate snapshots or platform-agnostic mocks.

When snapshots ARE useful:

  • Stable, presentational components (icons, badges)
  • Serialized data structures (API response normalization)
  • Error message formats
  • Small, focused output (< 20 lines)

Better alternatives:

// Instead of snapshot
expect(tree.toJSON()).toMatchSnapshot();

// Test specific behavior
expect(screen.getByText('Order #1234')).toBeTruthy();
expect(screen.getByRole('button', { name: 'Track Order' })).toBeTruthy();
expect(screen.queryByText('Cancelled')).toBeNull();

Rule of thumb: If you're updating snapshots more than once a sprint, switch to behavioral assertions.


Q7: How do you test React Native apps in CI/CD pipelines?

Answer:

CI pipeline stages:

# .github/workflows/test.yml
name: Test
on: [push, pull_request]

jobs:
  unit-and-component:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'npm'
      - run: npm ci
      - run: npx jest --coverage --ci --forceExit
      - uses: codecov/codecov-action@v4

  e2e-ios:
    runs-on: macos-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npx pod-install
      - run: npx detox build --configuration ios.sim.debug
      - run: npx detox test --configuration ios.sim.debug --cleanup

Key CI considerations:

1. Jest in CI:

npx jest --ci --forceExit --coverage --maxWorkers=2
  • --ci — Non-interactive, fail on snapshot mismatch
  • --forceExit — Prevent hanging from open handles
  • --maxWorkers=2 — Limit parallelism in CI environments

2. Coverage enforcement:

coverageThreshold: { global: { lines: 70 } }

3. E2E in CI:

  • Detox requires macOS for iOS, Linux emulator for Android
  • Use Maestro Cloud for device farm without local simulators
  • Record videos/screenshots on failure for debugging

4. Pre-commit hooks:

npx lint-staged  # Run related tests on staged files

5. Test parallelization:

npx jest --shard=1/3  # Split tests across CI matrix jobs

6. Caching:

  • Cache node_modules, Metro cache, Gradle/CocoaPods
  • Hermes bytecode compilation is slow — cache build artifacts

Flaky test management:

  • Retry E2E tests once on failure (not unit tests)
  • Quarantine consistently flaky tests
  • Track test reliability metrics over time

Best Practices Summary

Practice Reason
Follow the testing pyramid Fast feedback, maintainable test suite
Query by accessibility role/label Tests resemble user behavior
Mock at boundaries, not internals Tests survive refactoring
Use MSW for API mocking Realistic network behavior without server
Avoid excessive snapshot tests Reduce false positives and maintenance
Create test utility helpers Consistent setup (renderWithQueryClient, etc.)
Run tests in CI on every PR Catch regressions before merge
Test critical paths with E2E Highest confidence for user journeys
Reset mocks in beforeEach Prevent test pollution
Use --forceExit in CI Prevent hanging from native module handles

Navigation: ← Dependency Injection | Performance →