Detox E2E Testing for React Native: A Practical Deep Dive

Detox E2E Testing for React Native: A Practical Deep Dive

A hands-on guide to Detox E2E testing in React Native, covering project setup, device and element APIs, synchronization with async operations, and running Detox in CI environments like GitHub Actions.

Detox is the industry-standard end-to-end testing framework for React Native. Unlike Jest + RNTL which runs in Node.js against a virtual tree, Detox drives a real app binary on a real simulator or emulator. Your tests interact with buttons, text inputs, and navigation just like a human would — the app has no idea it's being tested.

This guide covers everything from initial setup to CI integration, with real code you can drop into an existing project.

How Detox Works

Detox runs two processes in parallel: your Jest test runner and the instrumented app binary. A WebSocket bridge connects them. When your test calls element(by.id('submit')).tap(), Detox sends that instruction to the app, waits for the app to reach an idle state (no pending timers, network requests, or animations), and then asserts the result.

The idle-waiting is the killer feature. It eliminates flaky sleep() calls that plague Appium tests.

Setup

Install dependencies

npm install --save-dev detox detox-cli jest-circus
npx detox init

Configure .detoxrc.js

/** @type {Detox.DetoxConfig} */
module.exports = {
  testRunner: {
    args: {
      $0: 'jest',
      config: 'e2e/jest.config.js',
    },
    jest: {
      setupTimeout: 120000,
    },
  },
  apps: {
    'ios.debug': {
      type: 'ios.app',
      binaryPath: 'ios/build/Build/Products/Debug-iphonesimulator/YourApp.app',
      build:
        'xcodebuild -workspace ios/YourApp.xcworkspace -scheme YourApp -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 16',
      },
    },
    emulator: {
      type: 'android.emulator',
      device: {
        avdName: 'Pixel_7_API_34',
      },
    },
  },
  configurations: {
    'ios.sim.debug': {
      device: 'simulator',
      app: 'ios.debug',
    },
    'android.emu.debug': {
      device: 'emulator',
      app: 'android.debug',
    },
  },
};

e2e/jest.config.js

/** @type {import('@jest/types').Config.InitialOptions} */
module.exports = {
  rootDir: '..',
  testMatch: ['<rootDir>/e2e/**/*.test.ts'],
  testTimeout: 120000,
  maxWorkers: 1,
  globalSetup: 'detox/runners/jest/globalSetup',
  globalTeardown: 'detox/runners/jest/globalTeardown',
  reporters: ['detox/runners/jest/reporter'],
  testEnvironment: 'detox/runners/jest/testEnvironment',
  verbose: true,
};

Your First Test

// e2e/login.test.ts
import { device, element, by, expect as detoxExpect } from 'detox';

describe('Login Flow', () => {
  beforeAll(async () => {
    await device.launchApp();
  });

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

  it('shows error for wrong 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 detoxExpect(element(by.text('Invalid credentials'))).toBeVisible();
  });

  it('navigates to home on success', async () => {
    await element(by.id('email-input')).typeText('alice@example.com');
    await element(by.id('password-input')).typeText('correctpassword');
    await element(by.id('login-button')).tap();

    await detoxExpect(element(by.id('home-screen'))).toBeVisible();
  });
});

Run it:

# Build once
npx detox build --configuration ios.sim.debug

# Run tests
npx detox test --configuration ios.sim.debug

Element Matchers

Detox provides several ways to locate elements in the running app.

by.id

The most reliable matcher. Set testID on your RN components:

<TouchableOpacity testID="checkout-button" onPress={handleCheckout}>
  <Text>Checkout</Text>
</TouchableOpacity>
await element(by.id('checkout-button')).tap();

by.text and by.label

// Visible text
await element(by.text('Add to Cart')).tap();

// Accessibility label (preferred for icons without text)
await element(by.label('Close modal')).tap();

by.type

Match by component type — useful for platform-specific components:

// Tap the first TextInput
await element(by.type('RCTTextInput')).typeText('hello');

Combining Matchers

// Element with both id and text (extra safety)
await element(by.id('confirm-btn').and(by.text('Confirm'))).tap();

// Ancestor/descendant
await element(by.id('user-card').withDescendant(by.text('Alice'))).tap();

Handling Multiple Elements

When a matcher returns multiple elements, use index:

// Tap the second item in a list
await element(by.id('list-item')).atIndex(1).tap();

Device API

The device object controls the app lifecycle and system state.

App Lifecycle

// Launch with custom launch args
await device.launchApp({
  newInstance: true,
  launchArgs: { detoxPrintBusyIdleResources: 'YES' },
  permissions: { notifications: 'YES', camera: 'YES' },
});

// Reload JS bundle (faster than full relaunch)
await device.reloadReactNative();

// Terminate and relaunch (clears all state)
await device.terminateApp();
await device.launchApp({ newInstance: true });

System Interactions

// Rotate device
await device.setOrientation('landscape');
await device.setOrientation('portrait');

// Simulate push notification
await device.sendUserNotification({
  trigger: { type: 'push' },
  title: 'New message',
  body: 'You have a new message',
  payload: { type: 'chat', roomId: '42' },
});

// Simulate URL open (deep link)
await device.openURL({ url: 'myapp://product/123' });

// Shake device
await device.shake();

Network Conditions (iOS)

// Simulate slow network
await device.setStatusBar({ networkType: 'edge' });

// Reset
await device.resetStatusBar();

Element Interactions

Typing and Clearing

// Type text (appends to existing value)
await element(by.id('search-input')).typeText('react native');

// Clear and type
await element(by.id('search-input')).clearText();
await element(by.id('search-input')).typeText('new value');

// Replace text entirely
await element(by.id('search-input')).replaceText('replacement');

Tapping

// Single tap
await element(by.id('button')).tap();

// Double tap
await element(by.id('image')).doubleTap();

// Long press
await element(by.id('item')).longPress();

// Tap at specific coordinates (relative to element)
await element(by.id('map')).tap({ x: 100, y: 200 });

Scrolling

// Scroll a ScrollView
await element(by.id('product-list')).scroll(500, 'down');
await element(by.id('product-list')).scroll(200, 'up');

// Scroll until element is visible
await element(by.id('product-list')).scrollTo('bottom');

// Swipe
await element(by.id('carousel')).swipe('left', 'fast');
await element(by.id('drawer')).swipe('right', 'slow', 0.75);

Sliders and Pickers

// Set slider value (0.0 to 1.0)
await element(by.id('volume-slider')).adjustSliderToPosition(0.7);

// Pick date (iOS)
await element(by.id('date-picker')).setDatePickerDate('2026-06-15', 'yyyy-MM-dd');

// Pick column value (iOS picker)
await element(by.id('state-picker')).setColumnToValue(0, 'California');

Expectations

// Visibility
await detoxExpect(element(by.id('modal'))).toBeVisible();
await detoxExpect(element(by.id('loading'))).not.toBeVisible();

// Existence in tree (even if off-screen)
await detoxExpect(element(by.id('hidden-item'))).toExist();
await detoxExpect(element(by.id('deleted-item'))).not.toExist();

// Text content
await detoxExpect(element(by.id('title'))).toHaveText('My Title');
await detoxExpect(element(by.id('input'))).toHaveValue('typed text');

// Focus
await detoxExpect(element(by.id('search-input'))).toBeFocused();

// Toggle state
await detoxExpect(element(by.id('toggle'))).toHaveToggleValue(true);

Synchronization — Handling Async Operations

Detox's synchronization is what makes it fast and reliable. It automatically waits for:

  • JS thread to be idle
  • Animation to complete
  • Network requests to finish (with nock or a custom adapter)
  • Timers shorter than 1.5 seconds

When Synchronization Breaks Down

Infinite timers (setInterval), long-polling, or streaming connections can prevent Detox from ever detecting idle. Fix this by disabling synchronization for specific operations:

await device.disableSynchronization();

// Interact with UI that has infinite timers
await element(by.id('live-feed')).tap();

await device.enableSynchronization();

Waiting for Custom Conditions

When you need explicit waiting:

import { waitFor } from 'detox';

// Wait up to 10 seconds for element to appear
await waitFor(element(by.id('result')))
  .toBeVisible()
  .withTimeout(10000);

// Wait while scrolling
await waitFor(element(by.text('Item 42')))
  .toBeVisible()
  .whileElement(by.id('list'))
  .scroll(100, 'down');

Test Lifecycle and State Management

A critical pattern: keep tests independent. Shared state between tests creates ordering dependencies and mysterious failures.

describe('Shopping Cart', () => {
  beforeAll(async () => {
    // Build + launch happens once per suite
    await device.launchApp({ newInstance: true });
  });

  beforeEach(async () => {
    // Full JS reload gives a clean slate for each test
    await device.reloadReactNative();
    // Navigate to the relevant screen
    await element(by.id('shop-tab')).tap();
  });

  it('adds item to cart', async () => {
    await element(by.id('add-to-cart-0')).tap();
    await element(by.id('cart-tab')).tap();
    await detoxExpect(element(by.id('cart-count'))).toHaveText('1');
  });

  it('removes item from cart', async () => {
    // This test starts fresh — no leftover cart from previous test
    await element(by.id('add-to-cart-0')).tap();
    await element(by.id('cart-tab')).tap();
    await element(by.id('remove-item-0')).tap();
    await detoxExpect(element(by.id('cart-empty-message'))).toBeVisible();
  });
});

CI Configuration — GitHub Actions

# .github/workflows/detox.yml
name: Detox E2E Tests

on:
  pull_request:
    branches: [main]

jobs:
  detox-ios:
    runs-on: macos-15
    timeout-minutes: 60

    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

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

      - name: Cache Detox build
        uses: actions/cache@v4
        with:
          path: ios/build
          key: detox-ios-${{ hashFiles('ios/**', 'package-lock.json') }}

      - name: Build app for Detox
        run: npx detox build --configuration ios.sim.debug

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

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

Android in CI

  detox-android:
    runs-on: ubuntu-latest
    timeout-minutes: 60

    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-java@v4
        with:
          distribution: temurin
          java-version: '17'

      - name: Enable KVM (faster emulator)
        run: |
          echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
          sudo udevadm control --reload-rules
          sudo udevadm trigger --name-match=kvm

      - uses: reactivecircus/android-emulator-runner@v2
        with:
          api-level: 34
          profile: pixel_7
          script: |
            npx detox build --configuration android.emu.debug
            npx detox test --configuration android.emu.debug --headless

Artifacts and Debugging

Detox captures screenshots, videos, and logs automatically on failure. Configure in .detoxrc.js:

artifacts: {
  rootDir: 'artifacts',
  plugins: {
    screenshot: { shouldTakeAutomaticScreenshots: true, keepOnlyFailedTestsArtifacts: true },
    video: { enabled: true, keepOnlyFailedTestsArtifacts: true },
    log: { enabled: true },
    timeline: { enabled: true },
  },
},

Summary

Detox gives React Native teams a reliable E2E testing layer that exercises the real app binary. The synchronization engine eliminates flaky sleeps, the device API covers everything from push notifications to orientation changes, and the CI setup is battle-tested across thousands of projects. Start with the happy path for your two or three most critical flows — login, checkout, onboarding — and add edge cases from there.

Read more

Start now free