← Back to DevBytes

Detox: Complete Testing Guide for Developers

What is Detox?

Detox is an end-to-end (E2E) testing framework specifically designed for React Native and native mobile applications. Unlike traditional testing tools that rely on WebDriver protocols or screenshot-based comparisons, Detox operates as a gray box testing framework — it communicates directly with the application under test through a specialized native layer, enabling fast, reliable, and deterministic test execution on real devices and simulators.

Developed by Wix and now widely adopted across the React Native ecosystem, Detox runs tests in JavaScript (or TypeScript) while leveraging platform-specific drivers to interact with UI elements at the native level. This architecture eliminates the flakiness commonly associated with black-box testing tools and provides a developer experience that feels natural to mobile engineers.

Detox currently supports both iOS (via EarlGrey-inspired mechanisms) and Android (via Espresso-inspired mechanisms), making it a cross-platform solution for mobile E2E testing.

Why Detox Matters

🚀 Deploy your AI agent in 10 minutes

Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.

Try it free →

Mobile E2E testing has historically been plagued by several challenges:

Detox addresses these problems head-on:

Getting Started with Detox

Prerequisites

Before installing Detox, ensure your development environment meets these requirements:

Installation

Install Detox and its associated test runner packages in your React Native project:

# Install Detox CLI globally (optional but recommended)
npm install -g detox-cli

# Install Detox core and Jest integration in your project
npm install --save-dev detox jest jest-circus

For React Native projects, you'll also need the platform-specific Detox native modules:

# iOS-specific native module
npm install --save-dev detox/detox-ios

# Android-specific native module
npm install --save-dev detox/detox-android

Project Setup — iOS Configuration

Create a .detoxrc.js or .detoxrc.json configuration file at the root of your project. This file defines test runner settings, device configurations, and app build parameters. Here's a comprehensive example supporting both platforms:

// .detoxrc.js
module.exports = {
  testRunner: {
    $0: 'jest',
    args: {
      config: 'e2e/jest.config.js',
      _: ['e2e'],
      maxWorkers: 1,
      bail: false,
    },
  },
  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 -quiet',
    },
    '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 -quiet',
    },
    'android.debug': {
      type: 'android.apk',
      binaryPath: 'android/app/build/outputs/apk/debug/app-debug.apk',
      build:
        'cd android && ./gradlew assembleDebug assembleAndroidTest -DtestBuildType=debug && cd ..',
    },
    'android.release': {
      type: 'android.apk',
      binaryPath: 'android/app/build/outputs/apk/release/app-release.apk',
      build:
        'cd android && ./gradlew assembleRelease assembleAndroidTest -DtestBuildType=release && cd ..',
    },
  },
  devices: {
    simulator: {
      type: 'ios.simulator',
      device: {
        type: 'iPhone 15',
        os: 'iOS 17.0',
      },
    },
    emulator: {
      type: 'android.emulator',
      device: {
        avdName: 'Pixel_6_API_34',
        avdDir: `${process.env.HOME}/.android/avd`,
      },
    },
  },
  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',
    },
  },
};

Jest Configuration for Detox

Create a dedicated Jest configuration file for your E2E tests. This isolates Detox tests from unit tests and configures the appropriate test environment:

// e2e/jest.config.js
module.exports = {
  rootDir: '..',
  testMatch: ['/e2e/**/*.test.js'],
  testTimeout: 120000,
  maxWorkers: 1,
  globalSetup: 'detox/runners/jest/globalSetup',
  globalTeardown: 'detox/runners/jest/globalTeardown',
  testEnvironment: 'detox/runners/jest/testEnvironment',
  reporters: ['detox/runners/jest/reporter'],
  verbose: true,
};

Initializing Detox in Your App

For iOS, ensure your React Native app's AppDelegate properly integrates Detox. The setup varies slightly between React Native versions, but the core requirement is enabling the Detox synchronization mechanism:

// ios/MyApp/AppDelegate.mm (React Native 0.72+)
#import <DetoxSync/DetoxSync.h>

// Inside your application:didFinishLaunchingWithOptions: method
// Add this line early in the method body
[[DetoxSync sharedInstance] enable];

For Android, Detox integration is handled automatically through the Gradle build process when you add the Detox dependency. The test APK is built alongside your app APK.

Writing Your First Detox Test

Test Structure

Detox tests follow a familiar describe/it/test pattern using Jest. Each test file typically focuses on a single screen or user flow. Here's a complete example testing a login screen:

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

describe('Login Screen', () => {
  beforeAll(async () => {
    await device.launchApp({
      newInstance: true,
      permissions: { notifications: 'YES' },
    });
  });

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

  afterAll(async () => {
    await device.terminateApp();
  });

  it('should display login form elements', async () => {
    await expect(element(by.id('email-input'))).toBeVisible();
    await expect(element(by.id('password-input'))).toBeVisible();
    await expect(element(by.id('login-button'))).toBeVisible();
  });

  it('should show validation error on empty submission', async () => {
    await element(by.id('login-button')).tap();
    await expect(element(by.text('Email is required'))).toBeVisible();
  });

  it('should navigate to dashboard on successful login', async () => {
    await element(by.id('email-input')).typeText('user@example.com');
    await element(by.id('password-input')).typeText('securePassword123');
    await element(by.id('login-button')).tap();

    // Wait for navigation to complete
    await waitFor(element(by.id('dashboard-screen')))
      .toBeVisible()
      .withTimeout(5000);

    await expect(element(by.id('welcome-message'))).toExist();
  });
});

Matchers and Actions

Detox provides a rich set of matchers to locate UI elements and actions to interact with them. Understanding these is fundamental to writing effective tests:

Common Matchers:

Common Actions:

Here's an example demonstrating multiple matchers and actions together:

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

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

  it('should scroll through products and interact with them', async () => {
    // Wait for the product list to load
    await waitFor(element(by.id('product-list')))
      .toBeVisible()
      .withTimeout(10000);

    // Scroll down to reveal more products
    await element(by.id('product-list')).scroll(200, 'down');

    // Find a specific product by its testID and tap it
    await element(by.id('product-card-42')).tap();

    // On the detail screen, verify product information
    await expect(element(by.text('Premium Widget'))).toBeVisible();
    await expect(element(by.label('Price: $29.99'))).toExist();

    // Swipe back or tap a back button
    await element(by.id('back-button')).tap();
  });

  it('should handle search functionality', async () => {
    await element(by.id('search-input')).tap();
    await element(by.id('search-input')).typeText('widget');
    
    // Verify search results appear
    await expect(element(by.text('Premium Widget'))).toBeVisible();
    await expect(element(by.text('Deluxe Widget'))).toBeVisible();
    
    // Verify non-matching products are not present
    await expect(element(by.text('Super Gadget'))).not.toBeVisible();
  });
});

Assertions and Expectations

Detox integrates with Jest's expect syntax and adds custom matchers for UI state verification:

// Various assertion examples

// Visibility checks
await expect(element(by.id('submit-button'))).toBeVisible();
await expect(element(by.id('loading-spinner'))).not.toBeVisible();

// Existence checks (element may exist but not be visible on screen)
await expect(element(by.id('off-screen-modal'))).toExist();
await expect(element(by.id('deleted-item'))).not.toExist();

// State-based assertions
await expect(element(by.id('toggle-switch'))).toHaveValue('true');
await expect(element(by.id('progress-bar'))).toHaveSliderPosition(0.75);

// Text content assertions
await expect(element(by.id('title'))).toHaveText('Welcome Back');
await expect(element(by.id('counter'))).toHaveLabel('5 items');

// Platform-specific traits (iOS accessibility traits)
await expect(element(by.id('header'))).toHaveTrait('header');

// Combined assertions with and/or logic
await expect(element(by.id('save-button'))).toBeVisible()
  .and.toHaveLabel('Save Changes');
await expect(element(by.id('error-banner'))).not.toExist()
  .or.not.toBeVisible();

Advanced Detox Techniques

Test Lifecycle and Hooks

Detox supports comprehensive test lifecycle hooks that allow you to set up preconditions, seed data, and clean up after tests. Leveraging these properly prevents test pollution and reduces flakiness:

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

describe('Checkout Flow', () => {
  beforeAll(async () => {
    // Called once before all tests in this describe block
    await device.launchApp({
      newInstance: true,
      launchArgs: {
        // Pass custom launch arguments to the app
        resetState: true,
        mockBackend: true,
      },
    });
  });

  beforeEach(async () => {
    // Reset to initial state before each test
    await device.reloadReactNative();
    
    // Optionally navigate to a specific screen
    await element(by.id('cart-tab')).tap();
  });

  afterEach(async () => {
    // Clean up after each test
    // For example, clear any persisted data via a mock endpoint
    await element(by.id('admin-menu')).tap();
    await element(by.id('clear-cart-button')).tap();
  });

  afterAll(async () => {
    // Full cleanup after all tests
    await device.terminateApp();
  });

  it('should complete checkout with valid payment', async () => {
    await element(by.id('checkout-button')).tap();
    await element(by.id('card-number-input')).typeText('4111111111111111');
    await element(by.id('cvv-input')).typeText('123');
    await element(by.id('expiry-input')).typeText('12/28');
    await element(by.id('place-order-button')).tap();
    
    await waitFor(element(by.text('Order Confirmed')))
      .toBeVisible()
      .withTimeout(10000);
  });
});

Mocking and Network Interception

For reliable E2E tests, you often need to control network responses. Detox supports several approaches to mocking:

Approach 1: Launch arguments — Pass flags to your app at launch time that trigger mock implementations:

// In your test file
await device.launchApp({
  newInstance: true,
  launchArgs: {
    mockServer: true,
    mockUserProfile: JSON.stringify({
      id: '123',
      name: 'Test User',
      email: 'test@example.com',
    }),
  },
});

// In your React Native app, read launch arguments
// and conditionally use mock data instead of real API calls
const LaunchArgs = require('react-native-launch-arguments');
const mockUserProfile = LaunchArgs.value('mockUserProfile');

Approach 2: Mock server — Run a local mock server (like MSW, MirageJS, or a simple Express server) that your app points to during testing:

// e2e/mock-server-setup.js
const express = require('express');

// Start a mock API server before tests
const startMockServer = () => {
  const app = express();
  
  app.get('/api/products', (req, res) => {
    res.json([
      { id: 1, name: 'Test Product A', price: 19.99 },
      { id: 2, name: 'Test Product B', price: 29.99 },
    ]);
  });

  app.post('/api/login', (req, res) => {
    res.json({ token: 'mock-jwt-token', user: { id: 1 } });
  });

  return app.listen(9090);
};

module.exports = { startMockServer };

Approach 3: Detox network plugins — Use Detox's built-in network interception capabilities (available in newer versions) to stub responses at the native layer without modifying your app code.

Handling Permissions and System Dialogs

Mobile apps frequently request permissions (camera, location, notifications) and encounter system dialogs. Detox provides mechanisms to handle these gracefully:

// Grant permissions at launch
await device.launchApp({
  newInstance: true,
  permissions: {
    camera: 'YES',
    location: 'always',
    notifications: 'YES',
    microphone: 'YES',
    photos: 'YES',
    calendar: 'YES',
    faceid: 'YES',
  },
});

// Handle system dialogs that appear during test execution
// iOS-specific: handle permission alert if it wasn't granted at launch
await device.dispatchNotification({
  trigger: 'push',
  title: 'Test Notification',
  body: 'This is a test notification body',
});

// Handle iOS location permission alert programmatically
await device.takePermissionAlert('location', 'always');

// Android-specific: handle system permission dialogs
await device.takePermissionAlert('camera');

CI/CD Integration

Running Detox tests in CI/CD pipelines requires additional configuration for headless execution and artifact management. Here's a battle-tested setup using a common CI configuration:

# .github/workflows/e2e-tests.yml (GitHub Actions example)
name: E2E Tests

on:
  pull_request:
    branches: [main, develop]
  push:
    branches: [main]

jobs:
  ios-e2e:
    runs-on: macos-14
    steps:
      - uses: actions/checkout@v4
      
      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '18'
          cache: 'npm'
      
      - name: Install dependencies
        run: npm ci
      
      - name: Install Detox CLI
        run: npm install -g detox-cli
      
      - name: Build iOS app for testing
        run: detox build --configuration ios.sim.release
      
      - name: Run iOS E2E tests
        run: detox test --configuration ios.sim.release --cleanup --record-logs all --artifacts-location artifacts/ios
      
      - name: Upload artifacts on failure
        if: failure()
        uses: actions/upload-artifact@v4
        with:
          name: detox-ios-artifacts
          path: artifacts/ios

  android-e2e:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      
      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '18'
          cache: 'npm'
      
      - name: Setup Java
        uses: actions/setup-java@v4
        with:
          java-version: '17'
          distribution: 'temurin'
      
      - name: Install dependencies
        run: npm ci
      
      - name: Install Detox CLI
        run: npm install -g detox-cli
      
      - name: Create Android emulator
        run: |
          echo "y" | $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --install "system-images;android-34;google_apis;x86_64"
          echo "no" | $ANDROID_HOME/cmdline-tools/latest/bin/avdmanager create avd --name detox_emulator --package "system-images;android-34;google_apis;x86_64" --device "pixel_6"
      
      - name: Start Android emulator
        run: |
          $ANDROID_HOME/emulator/emulator -avd detox_emulator -no-audio -no-window -gpu swiftshader &
          adb wait-for-device
      
      - name: Build Android app for testing
        run: detox build --configuration android.emu.debug
      
      - name: Run Android E2E tests
        run: detox test --configuration android.emu.debug --cleanup --record-logs all --artifacts-location artifacts/android
      
      - name: Upload artifacts on failure
        if: failure()
        uses: actions/upload-artifact@v4
        with:
          name: detox-android-artifacts
          path: artifacts/android

Best Practices for Detox Testing

1. Use testID Attributes Consistently

The most reliable way to target UI elements is through testID attributes. Add these to every interactive and assertable component in your React Native app:

// In your React Native component
<TouchableOpacity
  testID="login-button"
  accessibilityLabel="Log in to your account"
  onPress={handleLogin}
>
  <Text>Log In</Text>
</TouchableOpacity>

<TextInput
  testID="email-input"
  placeholder="Email"
  value={email}
  onChangeText={setEmail}
/>

This practice decouples test selectors from visual text, styling, or component structure — making tests resilient to UI changes.

2. Organize Tests by User Flows, Not Screens

Structure your E2E test suites around complete user journeys rather than individual screens. This mirrors real user behavior and catches integration issues that screen-isolated tests might miss:

// Good: Flow-based test organization
// e2e/flows/authentication.test.js — Login, signup, password reset
// e2e/flows/purchase.test.js — Browse, add to cart, checkout, receipt
// e2e/flows/profile.test.js — View profile, edit details, change avatar

// Less ideal: Screen-based organization
// e2e/screens/login-screen.test.js
// e2e/screens/cart-screen.test.js

3. Keep Tests Independent and Atomic

Each test should set up its own preconditions and not rely on state from previous tests. Use beforeEach to reset the app state:

beforeEach(async () => {
  // Full app reset between tests
  await device.reloadReactNative();
  // Alternatively, launch with fresh state
  // await device.launchApp({ newInstance: true });
});

4. Avoid Arbitrary Timeouts — Use waitFor

Never use setTimeout or sleep() in Detox tests. Instead, use waitFor() which synchronizes with the app's idle state:

// Bad: Arbitrary sleep
await new Promise(resolve => setTimeout(resolve, 3000));
await expect(element(by.id('loaded-content'))).toBeVisible();

// Good: Synchronized waiting
await waitFor(element(by.id('loaded-content')))
  .toBeVisible()
  .withTimeout(10000)
  .whileElement(by.id('loading-spinner'))
  .isVisible();

5. Implement a Screen Object Pattern

Encapsulate element selectors and common interactions in page object modules to reduce duplication and improve maintainability:

// e2e/screens/login.screen.js
class LoginScreen {
  get emailInput() {
    return element(by.id('email-input'));
  }

  get passwordInput() {
    return element(by.id('password-input'));
  }

  get loginButton() {
    return element(by.id('login-button'));
  }

  get errorMessage() {
    return element(by.id('login-error'));
  }

  async login(email, password) {
    await this.emailInput.clearText();
    await this.emailInput.typeText(email);
    await this.passwordInput.clearText();
    await this.passwordInput.typeText(password);
    await this.loginButton.tap();
  }

  async assertVisible() {
    await expect(this.emailInput).toBeVisible();
    await expect(this.loginButton).toBeVisible();
  }
}

module.exports = new LoginScreen();

// Usage in test file
const LoginScreen = require('../screens/login.screen');

it('should login successfully', async () => {
  await LoginScreen.assertVisible();
  await LoginScreen.login('test@example.com', 'password123');
  await waitFor(element(by.id('dashboard')))
    .toBeVisible()
    .withTimeout(5000);
});

6. Handle Async Operations Explicitly

Mobile apps are inherently asynchronous. Use Detox's synchronization mechanisms to handle animations, network requests, and state transitions:

// Wait for a network request to complete and UI to update
await waitFor(element(by.id('product-list-item-0')))
  .toBeVisible()
  .withTimeout(15000);

// Wait while a specific element is visible (loading indicator)
await waitFor(element(by.id('data-table')))
  .toBeVisible()
  .withTimeout(20000)
  .whileElement(by.id('loading-overlay'))
  .isVisible();

7. Use Descriptive Test Names and Tagging

Write test descriptions that clearly communicate the expected behavior. Use Jest's test tagging (via jest.test.tags or custom annotations) to categorize tests for selective execution:

// Clear, behavior-driven test names
it('should display an error when email format is invalid', async () => { ... });
it('should persist cart items across app restarts', async () => { ... });
it('should handle offline mode gracefully with cached data', async () => { ... });

// Tag tests for selective execution in CI
// smoke, regression, critical-path, slow, etc.

8. Leverage Detox Artifacts for Debugging

When tests fail, artifacts are invaluable. Configure Detox to capture logs, screenshots, and videos:

// In .detoxrc.js configurations, add artifact settings
// or pass as CLI arguments:
// detox test --artifacts-location ./artifacts --record-logs all --take-screenshots failing --record-videos failing

// In your CI pipeline, always upload artifacts on failure

9. Test on Both Platforms Strategically

While Detox supports both iOS and Android, you don't always need to run every test on both platforms. Consider a tiered approach:

10. Maintain a Healthy Test Suite

Regularly audit your E2E test suite:

Conclusion

Detox represents a paradigm shift in mobile E2E testing, moving away from fragile black-box approaches toward a robust gray-box architecture that delivers speed, reliability, and developer ergonomics. By synchronizing directly with the application's event loop, Detox eliminates the timing flakiness that has historically made mobile E2E testing painful and untrustworthy.

The framework's tight integration with React Native and native mobile platforms, combined with its Jest-based testing API, creates a natural and productive testing experience. From basic element interaction to advanced CI/CD pipeline integration, Detox provides the tools necessary to build a comprehensive, maintainable test suite that genuinely protects against regressions.

By following the practices outlined in this guide — consistent testID usage, flow-based test organization, proper synchronization with waitFor, screen object patterns, and strategic artifact management — development teams can establish an E2E testing culture that catches real bugs before they reach production, without slowing down the development cycle. As mobile applications continue to grow in complexity, investing in a solid Detox testing foundation pays dividends in application quality and developer confidence.

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles