Detox
Overview
Detox is the gray-box end-to-end testing framework for React Native apps by Wix. Tests are written in JavaScript, run by Jest on your machine, and drive the real app on an iOS simulator or Android emulator. Because Detox runs inside the app as well, it knows when animations, timers and network requests are in flight and waits for the app to become idle before every action and assertion, which removes most timing flakiness without sleep calls. The price is setup: Detox needs a test build of the app per platform and a few native changes on Android.
Instructions
Initial Assessment
- Platform — iOS, Android, or both? iOS tests only run on macOS.
- React Native version — bare React Native or Expo? Detox has no Expo-specific support, and the Expo guide that the Detox docs link to now covers Maestro; treat Detox on Expo as community-maintained.
- CI — Which CI provider? (GitHub Actions, CircleCI, Bitrise)
- Current state — New project or adding tests to existing app?
Setup
# Run in the React Native project root
npm install "jest@^29" --save-dev # pin the major so an old lockfile cannot pull Jest 26
npm install detox --save-dev
# iOS only (macOS): simulator control tool required by Detox
brew tap wix/brew
brew install applesimutils
# Creates .detoxrc.js, e2e/jest.config.js and e2e/starter.test.js
npx detox init
npm install -g detox-cli is optional: it only lets you type detox instead of npx detox.
Configuration
// .detoxrc.js — apps (how to build, where the binary is), devices, and their combinations
/** @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/FieldNotes.app',
build: 'xcodebuild -workspace ios/FieldNotes.xcworkspace -scheme FieldNotes -configuration Debug -sdk iphonesimulator -derivedDataPath ios/build',
},
'ios.release': {
type: 'ios.app',
binaryPath: 'ios/build/Build/Products/Release-iphonesimulator/FieldNotes.app',
build: 'xcodebuild -workspace ios/FieldNotes.xcworkspace -scheme FieldNotes -configuration Release -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',
reversePorts: [8081],
},
},
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' },
'ios.sim.release': { device: 'simulator', app: 'ios.release' },
'android.emu.debug': { device: 'emulator', app: 'android.debug' },
},
};
A simulator of that type must already exist (applesimutils --list): Detox clones an existing one and fails with Failed to find a device when there is none. detox init writes iPhone 15, which the GitHub macos-15 image does not have. avdName must be one that emulator -list-avds prints. detox build only runs the build string of the chosen configuration; if it fails, the fix is in the Xcode or Gradle command, not in Detox.
Jest Config for Detox
// e2e/jest.config.js — generated by `detox init`
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,
};
Android native setup
iOS needs no project changes. A bare Android project needs these build-script changes and three more below:
// android/build.gradle
allprojects {
repositories {
google()
+ maven { url("$rootDir/../node_modules/detox/Detox-android") }
}
}
// android/app/build.gradle
defaultConfig {
+ testBuildType System.getProperty('testBuildType', 'debug')
+ testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner'
}
dependencies {
+ androidTestImplementation('com.wix:detox:+')
}
Also: the Kotlin Gradle plugin on the buildscript classpath; a DetoxTest.java instrumentation test under android/app/src/androidTest/java/ in your app's package (copy it from the Detox "Project Setup" page and change the package line); and a network_security_config.xml that permits cleartext traffic to 10.0.2.2 and localhost, registered with android:networkSecurityConfig in AndroidManifest.xml. Release builds must add Detox's ProGuard file (node_modules/detox/android/detox/proguard-rules-app.pro), otherwise the test hangs or crashes at launch.
Writing Tests
// e2e/login.test.js — element(), by, expect, waitFor and device are Detox globals
describe('Login Flow', () => {
beforeAll(async () => {
await device.launchApp({ newInstance: true });
});
beforeEach(async () => {
await device.reloadReactNative();
});
it('should login successfully with valid credentials', async () => {
await element(by.id('email-input')).typeText('maya.chen@fieldnotes.app');
await element(by.id('password-input')).typeText(process.env.E2E_USER_PASSWORD);
await element(by.id('login-button')).tap();
await expect(element(by.id('home-screen'))).toBeVisible();
await expect(element(by.text('Welcome back'))).toBeVisible();
});
it('should show error for invalid credentials', async () => {
await element(by.id('email-input')).typeText('maya.chen@fieldnotes.app');
await element(by.id('password-input')).typeText('not-the-password');
await element(by.id('login-button')).tap();
await expect(element(by.id('error-message'))).toHaveText('Invalid credentials');
await expect(element(by.id('home-screen'))).not.toBeVisible();
});
});
by.id matches the React Native testID prop, which only works when it reaches a native component (View, Text, TextInput, Pressable); a custom component must forward it. Other matchers: by.text, by.label, by.type, combined with .and(), .withAncestor(), .withDescendant() and .atIndex(). Negate any assertion with .not; toBeNotVisible() and toNotExist() are deprecated.
Scrolling and Lists
// e2e/feed.test.js — scroll until an item appears, then pull to refresh
it('should load more items and refresh', async () => {
await waitFor(element(by.id('feed-item-10'))).toBeVisible().whileElement(by.id('feed-list')).scroll(300, 'down');
await element(by.id('feed-list')).scrollTo('top');
await element(by.id('feed-list')).swipe('down', 'slow'); // pull to refresh
await waitFor(element(by.id('refresh-indicator'))).not.toBeVisible().withTimeout(5000);
});
scroll(offset, direction) moves by points, scrollTo('bottom') jumps to an edge, swipe(direction) performs the gesture. A waitFor without withTimeout or whileElement does nothing.
Running Tests
npx detox build --configuration ios.sim.debug
npm start # debug builds need Metro running in another terminal
npx detox test --configuration ios.sim.debug
npx detox test -c ios.sim.debug e2e/login.test.js # one file
npx detox test -c ios.sim.debug --retries 2 # re-run failing files
npx detox test -c ios.sim.debug --reuse # keep the installed app, faster reruns
npx detox test -c android.emu.debug --headless # no emulator window
npx detox test -c ios.sim.release --record-logs failing --take-screenshots failing --record-videos failing
npx detox test -c ios.sim.debug --loglevel verbose --debug-synchronization 5000
Artifacts go to artifacts/ unless --artifacts-location says otherwise. Jest flags such as -t "login" pass through.
CI Integration
# .github/workflows/detox.yml — release build on an iOS simulator
name: Detox E2E
on: [push]
jobs:
detox-ios:
runs-on: macos-15
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 22
- run: npm ci
- run: brew tap wix/brew && brew install applesimutils
- run: cd ios && pod install
- run: npx detox build --configuration ios.sim.release
- run: npx detox test --configuration ios.sim.release --cleanup --headless --retries 2 --record-logs failing --take-screenshots failing
env:
E2E_USER_PASSWORD: ${{ secrets.E2E_USER_PASSWORD }}
- uses: actions/upload-artifact@v7
if: failure()
with:
name: detox-artifacts
path: artifacts
Test a release build in CI: it bundles the JavaScript, so no Metro server is needed. Android emulators need hardware virtualization (KVM) on the runner.
Examples
Example 1: Add a login test to an existing app and run it on iOS
User request: "We have Detox installed. Add an E2E test for the login screen and run it on the simulator."
Add testIDs to the screen, then create e2e/login.test.js from "Writing Tests" above:
<TextInput testID="email-input" value={email} onChangeText={setEmail} autoCapitalize="none" />
<TextInput testID="password-input" value={password} onChangeText={setPassword} secureTextEntry />
<Pressable testID="login-button" onPress={submit}><Text>Log in</Text></Pressable>
npx detox build -c ios.sim.debug
npm start & # Metro, stop it afterwards with: kill %1
E2E_USER_PASSWORD="$FIELDNOTES_E2E_PASSWORD" npx detox test -c ios.sim.debug e2e/login.test.js
Result: the simulator boots, the app is installed and launched, and Jest lists both tests of Login Flow as passed. If a testID is missing or not forwarded, the run fails with Test Failed: No elements found for “MATCHER(id == “login-button”)”; rerun with --loglevel verbose to print the view hierarchy.
Example 2: A test hangs on a screen with a spinner
User request: "Our Detox test just sits on the dashboard and times out. The app works fine by hand."
npx detox test -c ios.sim.debug e2e/dashboard.test.js --debug-synchronization 5000
The log names what keeps the app busy:
detox[90417] i The app is busy with the following tasks:
• UI elements are busy:
- View animations pending: 2.
• 1 network requests with URLs:
- URL #1: https://api.fieldnotes.app/v1/sync/poll
Exclude the long-polling endpoint from synchronization, and if a looping animation stays, wait manually around that one screen:
await device.launchApp({ newInstance: true });
await device.setURLBlacklist(['.*/v1/sync/poll.*']);
await device.disableSynchronization();
await element(by.id('tab-dashboard')).tap();
await waitFor(element(by.id('dashboard-total'))).toBeVisible().withTimeout(8000);
await element(by.id('tab-settings')).tap();
await device.enableSynchronization(); // only after leaving the busy screen
Result: the test no longer waits for the polling request, and the dashboard assertion passes within the 8-second window instead of timing out.
Guidelines
- Match by
testID (by.id) rather than by text: IDs survive copy changes and translations.
- Start each test from a known state:
device.launchApp({ newInstance: true }) is the safest, device.reloadReactNative() is faster but can glitch in complex apps, and launchApp({ delete: true }) or { resetAppState: true } clears stored data.
- Do not add sleeps. If Detox waits forever, find the busy resource with
--debug-synchronization: an endless loader, a looping animation, a setTimeout polling loop (use setInterval, which Detox ignores) or a long-lived request (blacklist its URL). Disabling synchronization is the last resort and is re-enabled on every new app launch.
- Keep
maxWorkers: 1 until the suite is stable; more workers boot more simulators and need tests that do not share accounts or server state.
- Use
--retries in CI to absorb rare device hiccups, but treat a test that needs retries locally as a bug.
- On Android prefer AOSP emulator images (
system-images;android-34;default;x86_64) over Google API images; they are lighter and more stable for automation.
- Keep test credentials in environment variables or CI secrets and point tests at a staging backend, never at production accounts.
- Permission dialogs: on iOS grant them at launch with
device.launchApp({ permissions: { notifications: 'YES', location: 'inuse' } }), or tap them through the experimental iOS-only system.element(by.system.label('Allow')).tap(). There is no system API on Android yet.
- Limits: Detox does not run on physical iOS devices and cannot drive other apps. For cross-app flows or apps that are not React Native, choose Appium or Maestro.