Detox: The E2E Framework That Runs Inside Your React Native App
End-to-end mobile tests have a reputation, and it is not a flattering one. They flake. A test passes ten times in a row, then fails on the eleventh for no reason you can name, and you spend an afternoon discovering that a network call took 200 milliseconds longer than usual. Detox exists to make that whole category of pain go away. It is a gray-box end-to-end testing framework for React Native (and native) apps, built and battle-tested by Wix, that drives a real build of your app on a simulator or emulator the way a real user would, while quietly synchronizing with the app's internal state so your tests never race ahead of the work.
The headline idea is simple to state and surprisingly hard to find elsewhere: Detox runs inside your app, not just outside it. Because it can see when the app is busy versus idle, it waits automatically before every action and assertion. No sleep(). No arbitrary timeouts. Just tests that do the right thing because the framework knows what the app is doing. With over 540,000 weekly downloads and active releases through 2026, it remains one of the most mature options in the React Native testing world.
Why Flaky Tests Happen, and Why Detox Does Not Have Them
To appreciate Detox you have to understand the enemy. The root cause of flaky E2E tests is timing. Your test fires an action, the app kicks off asynchronous work (a network request, an animation, a layout pass, a timer), and the test sprints to the next assertion before the app has caught up. Most mobile testing tools are black-box: they poke at the app from the outside and have no idea what is happening internally. The only tool they hand you is the sleep, and it is a terrible tool.
await driver.click(loginButton);
await sleep(3000); // hope the dashboard loaded by now
await expect(dashboard).toBeVisible();
That sleep(3000) is simultaneously too long (it slows every run) and too short (it still flakes when the network has a bad day). Detox takes the opposite bet. It is gray-box, meaning it links a native client directly into the app under test. That client tracks the app's async activity and reports idleness back to the test runner. Before Detox executes any action or expectation, it waits until the app is genuinely idle, monitoring all of these at once:
- Network requests in flight, waiting for responses.
- The main thread and native dispatch queues.
- UI layout, including React Native's shadow queue (the layout thread).
- Timers, with special handling for JavaScript's
setTimeout. - Animations, both React Native
Animatedandreact-native-reanimated. - The React Native bridge between JS and native code.
When all of those are quiet, and only then, Detox proceeds. As the docs put it, this "eliminates the need for that malpractice, and so introduces stability into the otherwise inherently-flaky test world."
What Lives Under the Hood
Detox is built from three cooperating pieces. The tester is your test logic running in a Node.js process on the host machine, using a standard JS test runner. The native client is the code linked into the app under test, which receives commands, drives native automation, and reports idleness. And the mediator server is a small WebSocket server on the host that relays messages between the two. That separation is deliberate: keeping communication routed through a relay means the connection survives app and device restarts, which is exactly the kind of thing that breaks brittle setups.
Underneath the synchronization layer, the actual UI automation rides on each platform's own testing engine: Espresso on Android and native automation on iOS. But the engines are the boring part. The idling-resource synchronization is what makes Detox reliable where other frameworks merely retry and hope.
Getting It Running
Install Detox as a dev dependency. The global CLI is optional but convenient.
# npm
npm install detox --save-dev
npm install -g detox-cli
# yarn
yarn add --dev detox
yarn global add detox-cli
On iOS you also need applesimutils (installable via Homebrew), and Android needs the usual SDK and emulator toolchain. Running detox init scaffolds a starter configuration for you. Be warned up front: because Detox links a native client into your binary, the initial setup goes through real native build tooling (Xcode and Gradle). Getting that first green run takes genuine effort. The payoff is reliability, but the entry fee is real.
Describing Your Apps and Devices
Modern Detox configuration lives in a .detoxrc.js file and is split into three concepts: apps (a buildable binary), devices (a simulator or emulator), and configurations (a pairing of one device with one app). A testRunner block wires it all to Jest, the officially supported runner.
/** @type {Detox.DetoxConfig} */
module.exports = {
testRunner: {
args: { $0: 'jest', config: 'e2e/jest.config.js' },
},
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',
},
'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_7_API_34' } },
},
configurations: {
'ios.sim.debug': { device: 'simulator', app: 'ios.debug' },
'android.emu.debug': { device: 'emulator', app: 'android.debug' },
},
};
The beauty of this structure is that one JavaScript test suite runs against both platforms. You build for a configuration, then test against it:
# Produce the binary the tests will run against
detox build --configuration ios.sim.debug
# Run the suite against that configuration (short flag: -c)
detox test --configuration ios.sim.debug
You always build before you test, because detox test runs against the binary at binaryPath. Swap ios.sim.debug for android.emu.debug and the same tests run on Android.
Writing Your First Real Test
Here is where Detox feels familiar and pleasant. Tests are plain describe/it blocks with async/await, so anyone who has written a unit test will be at home, breakpoints and all. The three building blocks are matchers (find an element), actions (do something to it), and expectations (assert its state).
describe('Login flow', () => {
beforeEach(async () => {
await device.reloadReactNative();
});
it('logs in and lands on the welcome screen', async () => {
await element(by.id('email')).typeText('john@example.com');
await element(by.id('password')).typeText('123456');
const loginButton = element(by.text('Login'));
await loginButton.tap();
await expect(loginButton).not.toExist();
await expect(element(by.label('Welcome'))).toBeVisible();
});
});
Notice what is not there: no waits, no sleeps, no retry loops. Between tap() and the next expect, Detox automatically waits for the network call, the navigation transition, and any animations to settle. The device.reloadReactNative() call in beforeEach reloads the JS bundle without a full app relaunch, which keeps the test suite fast.
Finding Elements Without the Flakiness
Detox gives you a rich set of matchers. You can locate elements by accessibility id, displayed text, accessibility label, native type, or accessibility traits, and you can combine them relationally.
// The recommended approach: match a unique testID
await element(by.id('submit-button')).tap();
// Match by visible text (watch out for i18n)
await element(by.text('Sign Up')).tap();
// Relational matching to disambiguate
await element(by.text('Delete').withAncestor(by.id('first-row'))).tap();
// Combine matchers, or pick by index when several match
await element(by.id('item').and(by.text('Active'))).tap();
await element(by.id('list-item')).atIndex(2).tap();
The single most useful best practice the docs hammer on: match by a unique testID via by.id rather than by text or label. Text matching breaks the moment someone translates your app or rewords a button, and by.id sidesteps that entirely.
Beyond Taps: Controlling the Whole Device
Real user flows are more than taps and typing, and Detox exposes the full device surface through the device object and a wide range of actions. You can simulate gestures, scroll until an element appears, and manipulate the running device itself.
// Gestures
await element(by.id('gallery')).swipe('left', 'fast');
await element(by.id('photo')).pinch(2.0); // iOS only
// Scroll a list until a specific element becomes visible
await waitFor(element(by.text('Bottom item')))
.toBeVisible()
.whileElement(by.id('feed'))
.scroll(200, 'down');
// Drive the device, not just the UI
await device.setOrientation('landscape');
await device.setLocation(37.7749, -122.4194);
await device.sendToHome(); // background the app
await device.launchApp({ newInstance: false }); // bring it back
A few of these are platform-specific by nature: pinch and picker column selection are iOS-only, scrollToIndex is Android-only, and by.traits only exists on iOS. Tests are not always perfectly portable between platforms, so it is worth keeping platform branches tidy where the gap shows up.
Deep Links, Permissions, and Push Notifications
Because Detox controls the launch of the app, it can put the app into specific states that are otherwise painful to reproduce. You can launch with permissions pre-granted, open a deep link, or simulate an incoming push notification, all from the test.
it('opens straight to a product via deep link', async () => {
await device.launchApp({
newInstance: true,
permissions: { notifications: 'YES', location: 'inuse' },
url: 'myapp://products/42',
});
await expect(element(by.id('product-42-title'))).toBeVisible();
});
it('reacts to an incoming push notification', async () => {
await device.sendUserNotification({
trigger: { type: 'push' },
payload: { title: 'New message', body: 'You have mail' },
});
await expect(element(by.text('New message'))).toBeVisible();
});
This turns flows that are normally manual QA chores into deterministic, repeatable tests. Pair it with Detox's artifact capture, which records screenshots, video, and logs during a run, and CI failures become genuinely debuggable rather than mysterious red marks.
The Honest Caveats
Detox is excellent, but it is not magic, and it is worth knowing where the edges are. The very synchronization that makes it reliable can also make it hang: an infinite or long-running timer, a polling loop, or a never-ending animation makes Detox think the app is permanently busy, so it waits forever. For those cases you reach for device.disableSynchronization() around the offending code, which sadly reintroduces a bit of manual waiting. Detox is also simulator-and-emulator-first; physical device support exists but is less robust, and some teams have reported flaky launches on real hardware. And running iOS simulators in CI means macOS runners, which are not cheap.
If you ship a pure React Native app, test primarily on simulators, and want the lowest possible timing flakiness with tests written in JavaScript or TypeScript, Detox is hard to beat. The gray-box synchronization is a genuinely different approach, and once you have felt a suite that never needs a sleep(), it is hard to go back. It is the rare testing tool that earns its setup cost by paying you back every single time the suite runs.