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 initConfigure .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.debugElement 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
nockor 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 --headlessArtifacts 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.