Skip to main content
← All Articles Β· Β· 10 min read

Mobile App Testing: Jest, Detox & Appium Strategy Guide

Complete mobile testing strategy for 2026: Jest + React Native Testing Library for unit tests, Detox for E2E on iOS/Android, Maestro as the simpler alternative, Appium for legacy, CI setup with GitHub Actions, and coverage strategy.

react-nativetestingjestdetoxappiummaestromobileci

Mobile App Testing: Jest, Detox & Appium Strategy Guide

Mobile testing is harder than web testing. Simulators are slow, E2E tests are brittle, and the test environment rarely matches production. But a well-structured testing strategy prevents the class of bugs that cost you App Store reviews and user trust. This guide covers every layer of the mobile testing pyramid with real-world configuration.

The Mobile Testing Pyramid

The same testing pyramid principles apply to mobile, but with important mobile-specific adjustments:

  • Unit tests (70%) β€” Fast, isolated, run on every commit. Test pure functions, custom hooks, business logic, and reducers. No simulator needed.
  • Component tests (20%) β€” Test components with React Native Testing Library. No simulator β€” they run in Node with a mock native environment.
  • E2E tests (10%) β€” Test critical user flows on a real simulator or device. Slow, but catch integration issues that unit tests miss.

The 10% E2E target is important β€” don’t write E2E tests for every feature. Write them for the flows that, if broken, would be catastrophic: login, checkout, core feature activation.

Jest + React Native Testing Library

React Native Testing Library (RNTL) is the standard for component testing. It provides render, fireEvent, screen, and waitFor β€” the same API as the web Testing Library.

Setup:

npm install --save-dev @testing-library/react-native @testing-library/jest-native
// jest.config.js
module.exports = {
  preset: 'react-native',
  setupFilesAfterFramework: ['@testing-library/jest-native/extend-expect'],
  moduleNameMapper: {
    '^@/(.*)$': '<rootDir>/src/$1',
  },
  transformIgnorePatterns: [
    'node_modules/(?!(react-native|@react-native|react-navigation|@react-navigation)/)',
  ],
};

Component test example:

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

jest.mock('../../api/auth');

describe('LoginScreen', () => {
  it('shows error message on invalid credentials', async () => {
    (authApi.login as jest.Mock).mockRejectedValue(
      new Error('Invalid credentials')
    );

    render(<LoginScreen />);

    fireEvent.changeText(screen.getByPlaceholderText('Email'), '[email protected]');
    fireEvent.changeText(screen.getByPlaceholderText('Password'), 'wrongpassword');
    fireEvent.press(screen.getByText('Sign In'));

    await waitFor(() => {
      expect(screen.getByText('Invalid credentials')).toBeTruthy();
    });
  });

  it('navigates to home on successful login', async () => {
    const mockNavigate = jest.fn();
    (authApi.login as jest.Mock).mockResolvedValue({ token: 'abc123' });

    render(<LoginScreen navigation={{ navigate: mockNavigate } as any} />);

    fireEvent.changeText(screen.getByPlaceholderText('Email'), '[email protected]');
    fireEvent.changeText(screen.getByPlaceholderText('Password'), 'correct');
    fireEvent.press(screen.getByText('Sign In'));

    await waitFor(() => {
      expect(mockNavigate).toHaveBeenCalledWith('Home');
    });
  });
});

Custom hook testing:

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

describe('useCartStore', () => {
  beforeEach(() => {
    useCartStore.setState({ items: [], total: 0 });
  });

  it('adds item and updates total', () => {
    const { result } = renderHook(() => useCartStore());

    act(() => {
      result.current.addItem({ id: '1', name: 'Widget', price: 9.99 });
    });

    expect(result.current.items).toHaveLength(1);
    expect(result.current.total).toBe(9.99);
  });
});

Snapshot Testing: Use Sparingly

Snapshot tests capture component output and fail when it changes. They’re useful for stable, dumb components but become a maintenance burden when overused:

// Good use case: a design-system button that should never change
it('renders primary button correctly', () => {
  const { toJSON } = render(<Button variant="primary" title="Submit" />);
  expect(toJSON()).toMatchSnapshot();
});

Avoid snapshot tests for screens with business logic β€” the snapshots become thousands of lines of auto-generated markup that nobody reads and everyone blindly updates with jest -u.

E2E with Detox

Detox is the industry-standard E2E testing framework for React Native. It runs on real iOS simulators and Android emulators, synchronizes with the app’s idle state, and provides a clean matcher API.

Installation:

npm install --save-dev detox @config-plugins/detox
npx detox init
// .detoxrc.js
module.exports = {
  testRunner: { $0: 'jest', args: { config: 'e2e/jest.config.js' } },
  apps: {
    'ios.debug': { type: 'ios.app', binaryPath: 'ios/build/Debug/MyApp.app', build: 'xcodebuild -workspace ios/MyApp.xcworkspace -scheme MyApp -configuration Debug -sdk iphonesimulator -derivedDataPath ios/build' },
    'android.debug': { type: 'android.apk', binaryPath: 'android/app/build/outputs/apk/debug/app-debug.apk', build: 'cd android && ./gradlew assembleDebug assembleAndroidTest -DtestBuildType=debug' },
  },
  devices: {
    simulator: { type: 'ios.simulator', device: { type: 'iPhone 15 Pro' } },
    emulator: { type: 'android.emulator', device: { avdName: 'Pixel_7_API_34' } },
  },
  configurations: {
    'ios.debug': { device: 'simulator', app: 'ios.debug' },
    'android.debug': { device: 'emulator', app: 'android.debug' },
  },
};
// e2e/auth.test.ts
import { device, element, by, expect as detoxExpect } from 'detox';

describe('Authentication Flow', () => {
  beforeAll(async () => {
    await device.launchApp({ newInstance: true });
  });

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

  it('should allow login with valid credentials', async () => {
    await element(by.id('email-input')).typeText('[email protected]');
    await element(by.id('password-input')).typeText('password123');
    await element(by.id('login-button')).tap();

    // Detox waits for idle state automatically
    await detoxExpect(element(by.id('home-screen'))).toBeVisible();
  });

  it('should show error on invalid password', async () => {
    await element(by.id('email-input')).typeText('[email protected]');
    await element(by.id('password-input')).typeText('wrongpassword');
    await element(by.id('login-button')).tap();

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

Add testID props to components that need E2E targeting β€” this is a required convention with Detox.

Maestro: Simpler E2E Alternative

Maestro is gaining traction as a simpler alternative to Detox. Tests are written in YAML, setup is minimal, and it handles flakiness better than most E2E frameworks through built-in retry logic.

# e2e/login.yaml
appId: com.myapp
---
- launchApp
- tapOn:
    id: "email-input"
- inputText: "[email protected]"
- tapOn:
    id: "password-input"
- inputText: "password123"
- tapOn:
    id: "login-button"
- assertVisible:
    id: "home-screen"
# Run Maestro tests
maestro test e2e/login.yaml

# Run all flows in a directory
maestro test e2e/

# Run in CI (headless)
maestro test --format junit e2e/

Maestro is ideal for teams that found Detox setup too complex. The trade-off is less fine-grained control and a smaller ecosystem.

Appium for Legacy and Cross-Platform

Appium uses WebDriver protocol to control real devices and simulators. It supports iOS, Android, and even Windows and macOS apps. The main reason to use Appium in 2026 is legacy projects or when you need tests that run on a device farm (AWS Device Farm, BrowserStack, Sauce Labs).

Appium is generally slower and more complex than Detox for React Native projects, but it’s the best choice when you need tests that work across dozens of real device configurations.

GitHub Actions CI Setup with Detox

# .github/workflows/e2e.yml
name: E2E Tests

on:
  pull_request:
    branches: [main]

jobs:
  e2e-ios:
    runs-on: macos-latest
    steps:
      - uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Install Detox CLI
        run: npm install -g detox-cli

      - name: Install Pods
        run: cd ios && pod install

      - name: Build iOS (Debug)
        run: detox build --configuration ios.debug

      - name: Run Detox tests
        run: detox test --configuration ios.debug --headless --record-logs all

      - name: Upload test artifacts
        if: failure()
        uses: actions/upload-artifact@v4
        with:
          name: detox-artifacts
          path: artifacts/

iOS E2E in CI requires macos-latest runners (3x the cost of ubuntu-latest). To manage costs, run E2E only on PRs to main, not on every push to feature branches.

Coverage Strategy

// jest.config.js β€” coverage configuration
module.exports = {
  preset: 'react-native',
  collectCoverageFrom: [
    'src/**/*.{ts,tsx}',
    '!src/**/*.stories.{ts,tsx}',
    '!src/**/__tests__/**',
    '!src/**/index.ts', // barrel files
  ],
  coverageThresholds: {
    global: {
      statements: 70,
      branches: 65,
      functions: 70,
      lines: 70,
    },
    // Higher threshold for business-critical modules
    './src/utils/payment/**': {
      statements: 95,
      branches: 90,
    },
  },
};

Aim for 70% global coverage but 90%+ for payment, auth, and data transformation logic. Don’t chase 100% β€” testing configuration files, navigation setup, and pure JSX layouts adds test maintenance cost without meaningful safety.

Testing Checklist

Before shipping a feature, verify:

  • Unit tests for all business logic and custom hooks
  • Component tests for forms and interactive components
  • Snapshot tests only for stable design-system primitives
  • E2E test updated if a critical user flow changed
  • CI passes on both iOS and Android configurations
  • Error states and loading states are tested, not just happy paths

Investing in testing infrastructure pays off when you’re pushing updates weekly and need confidence that auth, checkout, and core features still work after every refactor.

Free Download

50 CLI Commands That Save Hours Every Week

The exact Git shortcuts, file search tricks, Docker commands, and productivity hacks that senior developers use daily. Compiled from 5+ years of shipping production code.

  • Git mastery (10 cmds)
  • File & search wizardry
  • Docker & DevOps tricks
  • Process & network tools
  • Productivity boosters
  • macOS & Linux install guide

No spam. 1 email/week max. Unsubscribe anytime.

Found this guide useful? Check out our free developer tools.