تخطَّ إلى المحتوى

Detox Cheat Sheet

Overview

Detox is a gray-box end-to-end testing framework for React Native applications developed by Wix. Unlike black-box testing tools, Detox understands the internal workings of React Native apps, automatically synchronizing with the app’s state to eliminate flaky tests caused by timing issues. It waits for animations, network requests, and React Native bridge operations to complete before executing the next test step, resulting in reliable and deterministic test execution.

Detox tests run on actual iOS simulators and Android emulators, interacting with the app the same way a real user would. It supports element matching by various strategies, simulates user gestures, handles device operations like permissions and notifications, and integrates seamlessly with Jest as the test runner. Detox is designed for CI/CD environments with built-in device management, parallel test execution, and artifact collection including screenshots and videos of test runs.

Installation

# Prerequisites
# iOS: Xcode with command line tools, applesimutils
brew tap wix/brew
brew install applesimutils

# Android: Android SDK, Java JDK 11+, Android emulator

# Install Detox CLI globally
npm install -g detox-cli

# Install Detox in your React Native project
cd my-react-native-app
npm install --save-dev detox

# Initialize Detox configuration
detox init

# Install Jest adapter
npm install --save-dev @jest/globals

# Verify setup
detox test --configuration ios.sim.debug --list-devices

# Build the app for testing
detox build --configuration ios.sim.debug

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

Configuration

// .detoxrc.js
/** @type {Detox.DetoxConfig} */
module.exports = {
  logger: {
    level: process.env.CI ? 'debug' : 'info',
  },
  testRunner: {
    args: {
      config: 'e2e/jest.config.js',
      maxWorkers: process.env.CI ? 2 : 1,
      _: ['e2e'],
    },
    retries: process.env.CI ? 3 : 0,
  },
  apps: {
    'ios.debug': {
      type: 'ios.app',
      binaryPath: 'ios/build/Build/Products/Debug-iphonesimulator/MyApp.app',
      build: 'xcodebuild -workspace ios/MyApp.xcworkspace -scheme MyApp -configuration Debug -sdk iphonesimulator -derivedDataPath ios/build',
    },
    'ios.release': {
      type: 'ios.app',
      binaryPath: 'ios/build/Build/Products/Release-iphonesimulator/MyApp.app',
      build: 'xcodebuild -workspace ios/MyApp.xcworkspace -scheme MyApp -configuration Release -sdk iphonesimulator -derivedDataPath ios/build',
    },
    'android.debug': {
      type: 'android.apk',
      binaryPath: 'android/app/build/outputs/apk/debug/app-debug.apk',
      testBinaryPath: 'android/app/build/outputs/apk/androidTest/debug/app-debug-androidTest.apk',
      build: 'cd android && ./gradlew assembleDebug assembleAndroidTest -DtestBuildType=debug',
    },
    'android.release': {
      type: 'android.apk',
      binaryPath: 'android/app/build/outputs/apk/release/app-release.apk',
      testBinaryPath: 'android/app/build/outputs/apk/androidTest/release/app-release-androidTest.apk',
      build: 'cd android && ./gradlew assembleRelease assembleAndroidTest -DtestBuildType=release',
    },
  },
  devices: {
    simulator: {
      type: 'ios.simulator',
      device: { type: 'iPhone 15 Pro' },
    },
    emulator: {
      type: 'android.emulator',
      device: { avdName: 'Pixel_7_API_34' },
    },
  },
  configurations: {
    'ios.sim.debug': {
      device: 'simulator',
      app: 'ios.debug',
    },
    'ios.sim.release': {
      device: 'simulator',
      app: 'ios.release',
    },
    'android.emu.debug': {
      device: 'emulator',
      app: 'android.debug',
    },
    'android.emu.release': {
      device: 'emulator',
      app: 'android.release',
    },
  },
};
// e2e/jest.config.js
/** @type {import('@jest/types').Config.InitialOptions} */
module.exports = {
  rootDir: '..',
  testMatch: ['<rootDir>/e2e/**/*.test.js'],
  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,
};

Writing Tests

// e2e/login.test.js
const { device, element, by, expect, waitFor } = require('detox');

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

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

  it('should show login screen', async () => {
    await expect(element(by.id('loginScreen'))).toBeVisible();
    await expect(element(by.text('Welcome Back'))).toBeVisible();
  });

  it('should login with valid credentials', async () => {
    await element(by.id('usernameInput')).typeText('testuser');
    await element(by.id('passwordInput')).typeText('password123');
    await element(by.id('loginButton')).tap();
    
    await expect(element(by.id('homeScreen'))).toBeVisible();
    await expect(element(by.text('Hello, testuser'))).toBeVisible();
  });

  it('should show error for invalid credentials', async () => {
    await element(by.id('usernameInput')).typeText('wrong');
    await element(by.id('passwordInput')).typeText('wrong');
    await element(by.id('loginButton')).tap();
    
    await expect(element(by.text('Invalid credentials'))).toBeVisible();
  });
});

Element Matchers

MatcherDescription
by.id('testID')Match by testID prop
by.text('Hello')Match by exact text content
by.label('Submit')Match by accessibility label
by.type('RCTTextInput')Match by native view type
by.traits(['button'])Match by accessibility traits (iOS)
// Compound matchers
element(by.id('list')).atIndex(2);
element(by.id('item').withAncestor(by.id('scrollView')));
element(by.id('title').withDescendant(by.text('Hello')));

// Multiple matches
element(by.text('Submit')).atIndex(0);

// React Native testID in component
// <TouchableOpacity testID="loginButton">

Actions

// Tap and press
await element(by.id('button')).tap();
await element(by.id('button')).longPress();
await element(by.id('button')).longPress(2000);  // 2 second long press
await element(by.id('button')).multiTap(3);       // Triple tap

// Text input
await element(by.id('input')).typeText('Hello World');
await element(by.id('input')).replaceText('New Text');
await element(by.id('input')).clearText();
await element(by.id('input')).tapReturnKey();
await element(by.id('input')).tapBackspaceKey();

// Scrolling
await element(by.id('scrollView')).scroll(200, 'down');
await element(by.id('scrollView')).scroll(100, 'right');
await element(by.id('scrollView')).scrollTo('bottom');
await element(by.id('scrollView')).scrollTo('top');

// Scroll until element is visible
await waitFor(element(by.text('Last Item')))
  .toBeVisible()
  .whileElement(by.id('scrollView'))
  .scroll(100, 'down');

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

// Pinch (iOS)
await element(by.id('map')).pinch(1.5);  // Zoom in
await element(by.id('map')).pinch(0.5);  // Zoom out

// Set date picker
await element(by.id('datePicker')).setDatePickerDate('2025-01-15', 'yyyy-MM-dd');

// Adjust slider
await element(by.id('slider')).adjustSliderToPosition(0.75);

Expectations

// Visibility
await expect(element(by.id('header'))).toBeVisible();
await expect(element(by.id('loader'))).not.toBeVisible();
await expect(element(by.id('modal'))).toExist();
await expect(element(by.id('deleted'))).not.toExist();

// Text content
await expect(element(by.id('label'))).toHaveText('Hello World');
await expect(element(by.id('input'))).toHaveValue('typed text');

// Toggle state
await expect(element(by.id('switch'))).toHaveToggleValue(true);

// Focus
await expect(element(by.id('input'))).toBeFocused();

// Slider
await expect(element(by.id('slider'))).toHaveSliderPosition(0.5, 0.1);

Device Operations

// App lifecycle
await device.launchApp();
await device.launchApp({ newInstance: true });
await device.launchApp({ permissions: { notifications: 'YES', camera: 'YES' } });
await device.reloadReactNative();
await device.terminateApp();
await device.installApp();
await device.uninstallApp();

// Device actions
await device.sendToHome();
await device.shake();
await device.setBiometricEnrollment(true);
await device.matchFace();  // iOS Face ID
await device.matchFinger(); // iOS Touch ID

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

// Location
await device.setLocation(37.7749, -122.4194);

// URL scheme / Deep linking
await device.openURL({ url: 'myapp://profile/123' });

// User notifications
await device.sendUserNotification({
  trigger: { type: 'push' },
  title: 'New Message',
  body: 'You have a new message',
  payload: { type: 'message', id: '123' }
});

// Screenshots
await device.takeScreenshot('login-screen');

// Disable synchronization (for animations that never end)
await device.disableSynchronization();
// Re-enable
await device.enableSynchronization();

Wait For Conditions

// Wait for element to appear
await waitFor(element(by.id('welcome')))
  .toBeVisible()
  .withTimeout(5000);

// Wait for element to disappear
await waitFor(element(by.id('loader')))
  .not.toBeVisible()
  .withTimeout(10000);

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

// Wait for text
await waitFor(element(by.id('status')))
  .toHaveText('Complete')
  .withTimeout(30000);

Advanced Usage

// Network mocking (using jest)
beforeAll(async () => {
  await device.launchApp({
    launchArgs: { mockServer: 'http://localhost:9090' }
  });
});

// Custom Detox action
const scrollToBottom = async (scrollViewId) => {
  let isVisible = false;
  while (!isVisible) {
    try {
      await expect(element(by.id('endOfList'))).toBeVisible();
      isVisible = true;
    } catch {
      await element(by.id(scrollViewId)).scroll(300, 'down');
    }
  }
};

// Handling system dialogs (permissions)
await device.launchApp({
  permissions: {
    notifications: 'YES',
    camera: 'YES',
    location: 'always',
    photos: 'YES',
    microphone: 'YES',
  },
});

// Web view testing
await web.element(by.web.id('webButton')).tap();
await expect(web.element(by.web.cssSelector('.result'))).toHaveText('Success');

CLI Commands

# Build app
detox build --configuration ios.sim.debug

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

# Run specific test file
detox test --configuration ios.sim.debug e2e/login.test.js

# Run with grep (test name filter)
detox test --configuration ios.sim.debug --grep "login"

# Run with retries
detox test --configuration ios.sim.debug --retries 3

# Run with artifacts (screenshots, logs)
detox test --configuration ios.sim.debug --artifacts-location ./artifacts

# Clean up
detox clean-framework-cache
detox rebuild-framework-cache

# List available devices
detox test --list-devices

Troubleshooting

IssueSolution
Build failsEnsure correct Xcode/SDK version; clean build: detox clean-framework-cache
Element not foundUse testID prop in React Native components; check element is visible
Test flakinessAvoid sleep(); use waitFor() with timeouts; check synchronization
Synchronization timeoutDisable for long animations: device.disableSynchronization()
Android emulator not foundCheck AVD name matches config; start emulator manually first
iOS simulator not bootingReset simulator: Simulator > Device > Erase All Content
Keyboard overlapping inputUse tapReturnKey() after typing; adjust scroll position
Permission dialogs blockingSet permissions in device.launchApp() options
Screenshots blankWait for render; use device.takeScreenshot() after assertions
CI timeoutIncrease testTimeout in Jest config; optimize test setup