Detox End-to-End Testing for React Native
End-to-end testing for React Native apps has historically been painful. Tests were slow, brittle, and tied to specific device states that were hard to reproduce. Detox was built specifically to address these problems with a gray-box testing approach that gives your test runner insight into the app's internal state.
This guide covers everything from initial setup to CI integration and debugging the flaky tests that will inevitably appear.
What Makes Detox Different
Most mobile test frameworks treat the app as a black box — they send taps and swipes and assert on what appears on screen, with no awareness of what the app is doing internally. Detox takes a different approach: it hooks into the React Native runtime and synchronizes test execution with the JavaScript thread, animations, and network requests.
This means Detox waits for your app to become idle before proceeding to the next action. Instead of sleep(2000) calls scattered through your tests, Detox knows when the app has finished rendering and is ready for interaction.
| Approach | Synchronization | Speed | Flakiness |
|---|---|---|---|
| Black-box (Appium) | Manual sleeps / polling | Slow | High |
| Gray-box (Detox) | Auto-synchronized | Fast | Low |
| White-box (unit) | N/A | Very fast | Very low |
Installation and Setup
Detox requires Node.js, and for iOS you'll need a Mac with Xcode installed. Android requires the Android SDK.
npm install detox --save-dev
npm install jest --save-devInstall the Detox CLI globally:
npm install -g detox-cliAdd the Detox configuration to your package.json. The configurations key maps names to specific build and device setups:
{
"detox": {
"testRunner": {
"args": {
"$0": "jest",
"config": "e2e/jest.config.js"
}
},
"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 15" }
},
"emulator": {
"type": "android.emulator",
"device": { "avdName": "Pixel_6_API_33" }
}
},
"configurations": {
"ios.sim.debug": {
"device": "simulator",
"app": "ios.debug"
},
"android.emu.debug": {
"device": "emulator",
"app": "android.debug"
}
}
}
}Create the e2e directory and a Jest config:
// e2e/jest.config.js
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,
};Building the App for Testing
Before running tests, you need a debug build:
# iOS
detox build --configuration ios.sim.debug
# Android
detox build --configuration android.emu.debugYou only need to rebuild when native code changes. JavaScript changes are picked up automatically by the Metro bundler.
Writing Your First Test
Detox tests look like standard Jest tests with access to global device, element, expect, and by objects.
// e2e/login.test.js
describe('Login Screen', () => {
beforeAll(async () => {
await device.launchApp();
});
beforeEach(async () => {
await device.reloadReactNative();
});
it('should show validation error for empty email', async () => {
await element(by.id('login-button')).tap();
await expect(element(by.text('Email is required'))).toBeVisible();
});
it('should log in with valid credentials', async () => {
await element(by.id('email-input')).typeText('user@example.com');
await element(by.id('password-input')).typeText('password123');
await element(by.id('login-button')).tap();
await expect(element(by.id('home-screen'))).toBeVisible();
});
it('should show error for invalid 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 expect(element(by.text('Invalid credentials'))).toBeVisible();
});
});The by.id() matcher looks for testID props in your React Native components:
<TextInput
testID="email-input"
placeholder="Email"
onChangeText={setEmail}
/>
<TouchableOpacity testID="login-button" onPress={handleLogin}>
<Text>Log In</Text>
</TouchableOpacity>Matchers and Actions
Detox provides multiple ways to locate elements:
// By testID (recommended)
by.id('submit-button')
// By text content
by.text('Submit')
// By placeholder text
by.label('Enter your email')
// By type (React Native component type)
by.type('RCTTextInput')
// Combined matchers
by.id('list-item').withAncestor(by.id('product-list'))Common actions:
// Tap
await element(by.id('button')).tap();
// Long press
await element(by.id('card')).longPress();
// Type text
await element(by.id('input')).typeText('hello');
// Clear and retype
await element(by.id('input')).clearText();
await element(by.id('input')).typeText('new value');
// Scroll
await element(by.id('scroll-view')).scroll(200, 'down');
// Swipe
await element(by.id('card')).swipe('left', 'slow', 0.5);Assertions:
await expect(element(by.id('success-message'))).toBeVisible();
await expect(element(by.id('modal'))).not.toBeVisible();
await expect(element(by.id('counter'))).toHaveText('5');
await expect(element(by.id('toggle'))).toHaveToggleValue(true);Handling Async Operations
One of Detox's strongest features is automatic synchronization. When you tap a button that triggers an API call, Detox waits for the network request to complete and the UI to update before proceeding.
However, some async patterns need explicit handling:
// Waiting for an element to appear with a timeout
await waitFor(element(by.id('results-list')))
.toBeVisible()
.withTimeout(5000);
// Waiting for an element to disappear (e.g., loading spinner)
await waitFor(element(by.id('loading-spinner')))
.not.toBeVisible()
.withTimeout(10000);
// Scrolling until an element is visible
await waitFor(element(by.text('Load More')))
.toBeVisible()
.whileElement(by.id('scroll-view'))
.scroll(200, 'down');Device Control
Detox can control device state during tests:
// Simulate background/foreground
await device.sendToHome();
await device.launchApp({ newInstance: false });
// Rotate device
await device.setOrientation('landscape');
await device.setOrientation('portrait');
// Biometrics simulation
await device.setBiometricEnrollment(true);
await device.matchFace();
await device.unmatchFinger();
// Permissions
await device.launchApp({
permissions: { notifications: 'YES', camera: 'YES', location: 'always' }
});Mocking Network Requests
For tests that shouldn't depend on a real backend, you can intercept network requests. One common pattern is running a local mock server:
// e2e/setup/mockServer.js
const express = require('express');
const app = express();
app.use(express.json());
app.post('/api/login', (req, res) => {
const { email, password } = req.body;
if (email === 'user@example.com' && password === 'password123') {
res.json({ token: 'mock-token-123', userId: 'user-1' });
} else {
res.status(401).json({ error: 'Invalid credentials' });
}
});
module.exports = app;Use the globalSetup in your Jest config to start the server before tests run, and point your app at http://localhost:3001 for E2E tests via environment variables or a separate build configuration.
CI Integration with GitHub Actions
# .github/workflows/e2e.yml
name: Detox E2E Tests
on:
pull_request:
branches: [main]
jobs:
e2e-ios:
runs-on: macos-14
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Install Detox CLI
run: npm install -g detox-cli
- name: Cache CocoaPods
uses: actions/cache@v4
with:
path: ios/Pods
key: pods-${{ hashFiles('ios/Podfile.lock') }}
- name: Install CocoaPods
run: cd ios && pod install
- name: Build for testing
run: detox build --configuration ios.sim.debug
- name: Run E2E tests
run: detox test --configuration ios.sim.debug --headless --record-videos failing
- name: Upload test artifacts
if: failure()
uses: actions/upload-artifact@v4
with:
name: detox-artifacts
path: artifacts/The --record-videos failing flag records screen videos only for failing tests, which dramatically speeds up debugging without filling up your artifact storage.
Debugging Flaky Tests
Flaky tests are the most frustrating part of E2E testing. Here are the most common causes and fixes:
Animation interference. If your app has animations, Detox may interact with elements mid-animation. Disable animations in test builds:
// In your app's entry point
if (__DEV__ && process.env.DETOX_START_TIMESTAMP) {
require('react-native').UIManager.setLayoutAnimationEnabledExperimental(false);
}Non-deterministic test ordering. Tests that share state through the device will interfere with each other. Use beforeEach to reset to a known state:
beforeEach(async () => {
await device.reloadReactNative();
// Or for a full reset:
await device.launchApp({ delete: true });
});Timeout issues on CI. Some operations take longer on CI machines. Increase timeouts for known slow operations:
await waitFor(element(by.id('data-table')))
.toBeVisible()
.withTimeout(15000);Element not found after scroll. Use whileElement:
await waitFor(element(by.id('item-42')))
.toBeVisible()
.whileElement(by.id('flat-list'))
.scroll(100, 'down');Logging for diagnosis:
detox test --configuration ios.sim.debug --loglevel verboseCheck the artifacts/ directory after a run — Detox writes device logs, screenshots, and videos there.
Test Organization Patterns
For larger apps, organize tests by feature area rather than screen:
e2e/
auth/
login.test.js
registration.test.js
password-reset.test.js
checkout/
cart.test.js
payment.test.js
confirmation.test.js
utils/
helpers.js
mockServer.js
jest.config.jsExtract repeated sequences into helpers:
// e2e/utils/helpers.js
async function loginAs(email, password) {
await element(by.id('email-input')).typeText(email);
await element(by.id('password-input')).typeText(password);
await element(by.id('login-button')).tap();
await waitFor(element(by.id('home-screen')))
.toBeVisible()
.withTimeout(5000);
}
async function navigateTo(screen) {
await element(by.id('tab-' + screen)).tap();
await waitFor(element(by.id(screen + '-screen')))
.toBeVisible()
.withTimeout(3000);
}
module.exports = { loginAs, navigateTo };Running Tests
# Run all tests on iOS simulator
detox test --configuration ios.sim.debug
# Run a specific test file
detox test --configuration ios.sim.debug e2e/auth/login.test.js
# Run tests matching a pattern
detox test --configuration ios.sim.debug --testNamePattern "login"
# Run on Android emulator
detox test --configuration android.emu.debug
# Reuse existing app (skip reinstall)
detox test --configuration ios.sim.debug --reuseDetox is one of the most robust solutions available for React Native E2E testing. The gray-box synchronization eliminates the most common source of flakiness, and the tight integration with the React Native runtime makes it feel purpose-built for the platform — because it is.