Detox E2E Testing for React Native: Setup, Matchers, and Synchronization
End-to-end testing React Native apps is hard. You have two platforms, hardware variance, async bridge operations, and a runtime that doesn't behave like a browser. Detox was built specifically to solve this — it's a gray-box E2E framework that talks directly to your app's native layer and automatically synchronizes with async operations so you don't write sleep() calls everywhere.
This guide covers everything you need to go from zero to a working Detox test suite: installation, device configuration, matchers, actions, and the synchronization model that makes Detox actually reliable.
What Detox Is (and Isn't)
Detox is not a wrapper around Appium. It doesn't use UI Automator or XCUITest through a WebDriver protocol. Instead, it instruments your app at build time and maintains a gRPC connection to the running app, which lets it know exactly when the JavaScript thread and the native layer are idle.
This matters because the biggest source of flaky mobile tests is timing. When you tap a button that triggers an API call and then a screen transition, a naive test framework needs you to add arbitrary waits. Detox knows when the transition is complete because it watches the actual async queues.
What Detox handles:
- React Native's JS event queue
- Native animation queues
- Network requests (via mocking or real requests)
setTimeoutandsetInterval
What Detox does not handle:
- Tests that have nothing to do with the UI
- Logic that should be covered by Jest unit tests
- Complex mock server scenarios (you'll combine it with tools like
mswornock)
Installation
You need Node.js 16+, the React Native CLI (not Expo managed workflow without ejecting), and either Xcode (for iOS) or Android Studio (for Android).
npm install detox --save-dev
npm install jest --save-devInstall the Detox CLI globally:
npm install -g detox-cliNow initialize Detox in your project:
detox initThis creates e2e/ directory with a starter config and a .detoxrc.js file at the project root.
Configuring .detoxrc.js
The configuration file defines your app build artifacts and the devices to run on. A minimal cross-platform config:
/** @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',
reversePorts: [8081],
},
},
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',
},
},
};The reversePorts entry on Android is important if you're running the Metro bundler locally — it tells adb to reverse port 8081 so the emulator can reach your machine's Metro server.
Writing Your First Test
Detox tests are Jest test files in the e2e/ directory. The API surface has three main concepts: device, element, and expect.
// e2e/login.test.js
describe('Login Screen', () => {
beforeAll(async () => {
await device.launchApp();
});
beforeEach(async () => {
await device.reloadReactNative();
});
it('should show error on wrong credentials', async () => {
await element(by.id('email-input')).typeText('bad@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();
});
it('should navigate to home on valid login', async () => {
await element(by.id('email-input')).typeText('user@example.com');
await element(by.id('password-input')).typeText('correct-password');
await element(by.id('login-button')).tap();
await expect(element(by.id('home-screen'))).toBeVisible();
});
});The by.id() matcher uses the testID prop in React Native. Add these to your components:
<TextInput
testID="email-input"
placeholder="Email"
onChangeText={setEmail}
/>
<TouchableOpacity testID="login-button" onPress={handleLogin}>
<Text>Login</Text>
</TouchableOpacity>Matchers
Detox gives you several ways to select elements:
// By testID (most reliable)
by.id('submit-button')
// By visible text
by.text('Submit')
// By accessibility label
by.label('Submit button')
// By element type (use sparingly — fragile)
by.type('RCTTextView')
// Combining matchers
by.id('list-item').withAncestor(by.id('product-list'))
// Index for when multiple elements match
by.id('list-item').atIndex(2)The by.id() approach is the most stable across OS versions and refactors. Make it your default.
Actions
Actions are what your test does to elements:
// Tap
await element(by.id('button')).tap();
// Long press
await element(by.id('card')).longPress();
// Type text (clears first by default in newer versions)
await element(by.id('search')).typeText('react native');
// Clear and type
await element(by.id('search')).clearText();
await element(by.id('search')).typeText('react native');
// Scroll
await element(by.id('scroll-view')).scroll(200, 'down');
// Scroll to element
await element(by.id('scroll-view')).scrollTo('bottom');
// Swipe
await element(by.id('card')).swipe('left', 'fast', 0.75);
// Pinch (iOS only)
await element(by.id('map')).pinchWithAngle('outward', 'slow', 0);For lists, Detox has a dedicated scroll API that handles FlatList and ScrollView:
await waitFor(element(by.id('product-item-42')))
.toBeVisible()
.whileElement(by.id('product-list'))
.scroll(100, 'down');Assertions
Detox expectations check element state:
// Visibility
await expect(element(by.id('modal'))).toBeVisible();
await expect(element(by.id('spinner'))).not.toBeVisible();
// Existence in tree (even if off-screen)
await expect(element(by.id('modal'))).toExist();
await expect(element(by.id('deleted-item'))).not.toExist();
// Text content
await expect(element(by.id('counter'))).toHaveText('42');
// Input value
await expect(element(by.id('email-input'))).toHaveValue('user@example.com');The waitFor API — Handling Async
Sometimes Detox's automatic synchronization isn't enough — for example, when you're waiting for a network response that Detox can't track (like a WebSocket update). Use waitFor with a timeout:
// Wait up to 5 seconds for element to appear
await waitFor(element(by.id('success-toast')))
.toBeVisible()
.withTimeout(5000);
// Wait for element while performing an action (like scrolling)
await waitFor(element(by.text('Item 50')))
.toBeVisible()
.whileElement(by.id('main-list'))
.scroll(100, 'down');The synchronization model is what separates Detox from everything else. By default, it waits for:
- All pending JS microtasks to drain
- All native animation frames to complete
- All active network requests (if you use Detox's network layer) to settle
If your app uses setNativeDriver: true on animations, those animations are invisible to Detox's sync — they run on the native thread. You'll need to disable native animations in test mode:
// In your app entry point
if (__DEV__) {
const { default: DevSettings } = require('react-native/Libraries/Utilities/DevSettings');
}
// Better approach — check for test environment
if (process.env.DETOX_DISABLE_NATIVE_ANIMATIONS) {
// Patch Animated to disable useNativeDriver
}Or set it in the Detox config:
// .detoxrc.js
apps: {
'ios.debug': {
type: 'ios.app',
// ...
launchArgs: {
detoxDisableHierarchyDump: 'YES',
},
},
}Managing App State Between Tests
Detox gives you several strategies:
// Relaunch fresh (slowest — full app restart)
await device.launchApp({ newInstance: true });
// Reload JS bundle (fast — keeps native state)
await device.reloadReactNative();
// Send deep link to reach a specific screen
await device.launchApp({
newInstance: false,
url: 'myapp://product/123',
});
// Set AsyncStorage or other storage via app launch args
await device.launchApp({
launchArgs: { isTestMode: true },
});Read the launch arg in your app:
import { DevSettings } from 'react-native';
// or use react-native-launch-arguments
import RNLaunchArguments from 'react-native-launch-arguments';
const isTestMode = RNLaunchArguments.value('isTestMode');Network Mocking
For deterministic tests, you want to control API responses. Detox works well with msw (Mock Service Worker) or a local mock server:
// e2e/setup.js
beforeAll(async () => {
// Start your mock server
mockServer.start(3001);
await device.launchApp({
launchArgs: {
API_BASE_URL: 'http://localhost:3001',
},
});
});
afterAll(() => {
mockServer.stop();
});You can also intercept requests using Detox's built-in network layer in newer versions.
Running Tests
Build first, then test:
# iOS
detox build --configuration ios.sim.debug
detox <span class="hljs-built_in">test --configuration ios.sim.debug
<span class="hljs-comment"># Android
detox build --configuration android.emu.debug
detox <span class="hljs-built_in">test --configuration android.emu.debug
<span class="hljs-comment"># Run a single test file
detox <span class="hljs-built_in">test --configuration ios.sim.debug e2e/login.test.js
<span class="hljs-comment"># Run with a specific testNamePattern
detox <span class="hljs-built_in">test --configuration ios.sim.debug --testNamePattern <span class="hljs-string">"should navigate"For CI, add --headless for Android and use a pre-built artifact to separate the build and test steps.
Debugging Failing Tests
When a test fails, Detox writes artifacts to a directory configured in .detoxrc.js:
artifacts: {
plugins: {
screenshot: { shouldTakeAutomaticScreenshots: true, keepOnlyFailedTestsArtifacts: true },
video: { enabled: false }, // Enable for debugging
log: { enabled: true },
},
rootDir: 'e2e/artifacts',
},Use --take-screenshots failing in CI to capture screenshots only on failure.
For interactive debugging, run with --debug-synchronization 1000 to log what Detox is waiting for when synchronization takes longer than 1 second:
detox test --configuration ios.sim.debug --debug-synchronization 1000Real-World Test Patterns
Testing navigation flows
it('should complete checkout flow', async () => {
// Start from product list
await expect(element(by.id('product-list'))).toBeVisible();
// Tap a product
await element(by.id('product-item')).atIndex(0).tap();
await expect(element(by.id('product-detail'))).toBeVisible();
// Add to cart
await element(by.id('add-to-cart-button')).tap();
await expect(element(by.id('cart-badge'))).toHaveText('1');
// Navigate to cart
await element(by.id('cart-tab')).tap();
await expect(element(by.id('cart-screen'))).toBeVisible();
// Checkout
await element(by.id('checkout-button')).tap();
await expect(element(by.id('payment-screen'))).toBeVisible();
});Testing offline behavior
it('should show offline banner when disconnected', async () => {
await device.setURLBlacklist(['.*api.example.com.*']);
await element(by.id('refresh-button')).tap();
await expect(element(by.id('offline-banner'))).toBeVisible();
await device.setURLBlacklist([]);
});Pairing Detox with Continuous Monitoring
E2E tests catch regressions in your dev cycle, but production is a different environment. What passes on an emulator can fail on real devices due to device-specific rendering, OS versions, or API latency.
If you're running Detox tests in CI and want to extend that coverage to production monitoring — testing real user flows against your live app on a schedule — HelpMeTest is built for that. You define flows in plain English, it runs them on real devices continuously, and alerts you when something breaks. It complements your Detox suite rather than replacing it: Detox catches regressions during development, HelpMeTest catches them in production before your users do.
Conclusion
Detox's gray-box approach — instrumenting the app and synchronizing with its async operations — is what makes it reliable enough to use in CI. The key practices:
- Use
testIDprops everywhere, not text or accessibility labels - Prefer
device.reloadReactNative()overdevice.launchApp({ newInstance: true })for speed - Mock network at the server level, not at the JS module level
- Use
waitForonly when automatic synchronization can't track the async operation - Separate build and test steps in CI — never build in the test step
The initial setup is the hard part. Once your first test is green, adding coverage is straightforward.