Detox React Native E2E Testing Guide (2026)
Learn Detox for React Native E2E testing in 2026: gray-box automatic synchronization, matchers, actions, assertions, CI setup, and how to kill flaky tests.
Detox React Native E2E Testing Guide
Detox is a gray-box end-to-end testing framework for React Native that runs your app on a real device or simulator and drives it the way a user would — tapping, typing, scrolling, and asserting on what is on screen. Its defining feature is automatic synchronization: Detox monitors the app's native run loop, network requests, timers, and animations, and only fires the next action once the app is genuinely idle. That single design decision eliminates the sleep()-driven flakiness that plagues most mobile E2E suites. In 2026 Detox remains the most reliable choice for RN E2E on iOS and Android.
This guide covers installation, the matcher/action/assertion API, a full end-to-end example, running in CI, and the synchronization techniques that keep suites green. For installable, agent-ready QA skills you can drop into Claude Code or Cursor, browse the QASkills directory.
Gray-box vs black-box: why it matters
Most E2E tools (Appium, for example) are black-box: they sit outside the app and have no idea what it is doing internally, so they poll the UI and you bolt on explicit waits. Detox is gray-box — it is compiled into your app's test build and has privileged knowledge of the React Native bridge, the native event loop, and outstanding async work. Because it can see when the JavaScript thread, the native UI thread, network calls, and animations have all settled, it knows the precise moment the app is ready for the next interaction. You almost never write a manual wait.
The trade-off: Detox requires a dedicated test build of your app and is React Native-specific. If you need to test arbitrary native apps you did not build, that is Appium's territory. For your own RN app, gray-box wins on stability.
Installation and setup
Detox ships as an npm package plus a native test runner. The default and recommended JavaScript runner is Jest.
npm install detox jest @types/jest --save-dev
npx detox init
detox init scaffolds a .detoxrc.js configuration file and an e2e/ folder with a Jest config and a starter test. You then describe your app binaries and devices in .detoxrc.js:
/** @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' },
},
};
You build the test binary once, then run tests against it:
detox build --configuration ios.sim.debug
detox test --configuration ios.sim.debug
detox build simply runs the build command from your config; detox test boots the device, installs the binary, and runs Jest.
The core API: matchers, actions, assertions
Detox exposes four globals inside tests: device, element, by, and expect. The mental model is always the same — match an element, perform an action on it, then assert on it.
Matchers (by)
Matchers find elements. The most robust matcher is by test ID, which maps to the testID prop in your React Native components:
<TextInput testID="email-input" />
<Button testID="login-button" title="Log in" />
element(by.id('email-input')); // by testID — preferred
element(by.text('Log in')); // by visible text
element(by.label('Submit')); // by accessibility label
element(by.type('RCTImageView')); // by native class
element(by.traits(['button'])); // iOS accessibility traits
You can combine matchers to disambiguate, and index into lists when several elements match:
element(by.id('row').withAncestor(by.id('list')));
element(by.text('Delete')).atIndex(0);
Always prefer by.id. Text and label matchers break when copy changes or under localization.
Actions
Actions are async — always await them. Detox's auto-sync means you do not wrap them in manual waits.
await element(by.id('email-input')).typeText('user@example.com');
await element(by.id('password-input')).typeText('hunter2\n');
await element(by.id('login-button')).tap();
await element(by.id('feed')).scroll(300, 'down');
await element(by.id('feed')).scrollTo('bottom');
await element(by.id('row-42')).swipe('left', 'fast');
await element(by.id('search')).clearText();
await element(by.id('search')).replaceText('detox'); // faster than typeText
replaceText sets the field value in one shot and is dramatically faster than typeText for long strings — use it unless you specifically need to test per-keystroke behavior.
Assertions (expect)
await expect(element(by.id('welcome'))).toBeVisible();
await expect(element(by.id('spinner'))).not.toBeVisible();
await expect(element(by.id('title'))).toHaveText('Dashboard');
await expect(element(by.id('checkbox'))).toExist();
await expect(element(by.id('badge'))).toHaveLabel('3 unread');
Note toBeVisible() asserts the element is on screen and not obscured, while toExist() only asserts it is in the hierarchy (it may be scrolled off). Choose deliberately — a common false-pass is asserting existence when you meant visibility.
A realistic end-to-end test
Here is a complete login-to-dashboard flow demonstrating lifecycle hooks, navigation, and assertions.
describe('Authentication', () => {
beforeAll(async () => {
await device.launchApp({ newInstance: true, permissions: { notifications: 'YES' } });
});
beforeEach(async () => {
await device.reloadReactNative();
});
it('logs in with valid credentials and lands on the dashboard', async () => {
await element(by.id('email-input')).typeText('user@example.com');
await element(by.id('password-input')).replaceText('correct-horse');
await element(by.id('login-button')).tap();
await expect(element(by.id('dashboard-header'))).toBeVisible();
await expect(element(by.id('greeting'))).toHaveText('Welcome back');
});
it('shows an inline error for a bad password', async () => {
await element(by.id('email-input')).typeText('user@example.com');
await element(by.id('password-input')).replaceText('wrong');
await element(by.id('login-button')).tap();
await expect(element(by.id('auth-error'))).toBeVisible();
await expect(element(by.id('auth-error'))).toHaveText('Invalid credentials');
});
});
device.reloadReactNative() resets the JS bundle between tests far faster than a full relaunch, giving you clean state without the cost of launchApp. Use launchApp({ newInstance: true }) when you need to reset native state (permissions, deep links, killed process).
Synchronization and killing flakiness
Detox is idle-aware by default, so most timing problems disappear. The remaining flakiness usually comes from work Detox cannot see. Three techniques cover almost every case.
1. Let auto-sync do its job. Do not add setTimeout or manual sleeps. If a test passes only with a sleep, you are masking a real synchronization gap — find it instead.
2. Use waitFor for genuinely indeterminate UI. When something appears after an event Detox does not track (a push notification, a websocket message), poll explicitly with a timeout:
await waitFor(element(by.id('new-message')))
.toBeVisible()
.withTimeout(5000);
// Combine with a scroll action when the target may be off-screen:
await waitFor(element(by.id('row-99')))
.toBeVisible()
.whileElement(by.id('feed'))
.scroll(200, 'down');
3. Tame infinite animations and timers. A looping animation (a spinner that never stops, a setInterval) keeps the app perpetually "busy," so Detox waits forever and the action times out. Either drive looping animations with the native driver where appropriate, or temporarily disable synchronization around the known-busy region:
await device.disableSynchronization();
await element(by.id('open-loader')).tap();
await waitFor(element(by.id('result'))).toBeVisible().withTimeout(8000);
await device.enableSynchronization();
Reach for disableSynchronization() sparingly and re-enable immediately — it is an escape hatch, not a default. For mocking the network so tests are deterministic, intercept at the app layer (e.g. a mock server or MSW in the test build) rather than relying on real backends. See the broader testing-strategy notes on the QASkills blog, and compare adjacent mobile tools on /compare.
Launch state, deep links, and permissions
Reliable E2E suites control the app's starting state instead of clicking through preamble every time. device.launchApp() accepts options that put the app into a known condition:
// Reset to a clean install state and pre-grant permissions
await device.launchApp({
newInstance: true,
delete: true, // reinstall for truly clean state
permissions: { notifications: 'YES', camera: 'NO', location: 'inuse' },
launchArgs: { uiTesting: true, apiBaseUrl: 'http://localhost:8080' },
});
permissions answers iOS system dialogs up front so they never block a test. launchArgs are read by your app to enable a deterministic test mode — seeded data, disabled animations, a mock backend — which is the single highest-leverage habit for stable Detox suites.
Deep links let you jump straight to the screen under test rather than navigating from the home screen:
await device.launchApp({ newInstance: true, url: 'myapp://product/42' });
// Or open a URL on an already-running app:
await device.openURL({ url: 'myapp://settings/notifications' });
You can also send the app to the background and bring it back to verify state restoration, which catches a whole class of lifecycle bugs:
await device.sendToHome();
await device.launchApp({ newInstance: false }); // resume the existing instance
await expect(element(by.id('draft'))).toHaveText('unsaved changes');
Capturing artifacts is built in — pass --take-screenshots failing --record-videos failing on the CLI, or grab a named screenshot at a key moment with await device.takeScreenshot('after-login') so failures are diagnosable without reproducing locally.
Running Detox in CI
Detox runs headless on CI. On Linux, use Android emulators; for iOS you need a macOS runner. A typical GitHub Actions Android job:
jobs:
detox-android:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '20' }
- run: npm ci
- name: Build Detox app
run: npx detox build --configuration android.emu.debug
- name: Enable KVM
run: |
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666"' | sudo tee /etc/udev/rules.d/99-kvm.rules
sudo udevadm control --reload-rules && sudo udevadm trigger --name-match=kvm
- name: Run Detox tests
uses: reactivecircus/android-emulator-runner@v2
with:
api-level: 34
arch: x86_64
script: npx detox test --configuration android.emu.debug --cleanup --headless
The --cleanup flag shuts down the device after the run, and --headless skips the emulator window. Add --record-logs all and --take-screenshots failing to capture artifacts for failed tests so you can debug CI-only failures.
Common errors and troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Test times out waiting for an action | App never goes idle (looping animation/timer) | Use native-driven animations, or wrap region in disableSynchronization() |
Cannot find element | Wrong or missing testID | Add stable testID props; avoid text/label matchers |
| Passes locally, fails in CI | Emulator slowness or animations enabled | Disable animations on the device; raise withTimeout for known-slow screens |
toBeVisible fails but element exists | Element scrolled off or obscured | Scroll into view with whileElement(...).scroll() or assert toExist() |
| Flaky taps after navigation | Acting before navigation animation finishes | Trust auto-sync; if a custom transition isn't tracked, waitFor the destination |
When you hit a true synchronization mystery, run detox test --debug-synchronization 3000 — Detox prints what resource (network, timers, JS, UI) is keeping the app busy, which almost always pinpoints the root cause.
Frequently Asked Questions
Is Detox better than Appium for React Native?
For apps you build yourself, yes — Detox's gray-box auto-synchronization removes the explicit-wait flakiness that dominates Appium suites, and it is faster because it runs in-process. Appium is the better choice when you must test native apps you did not build, need a single tool across heterogeneous native and hybrid apps, or require a wider matrix of real devices via a cloud grid.
Do I need to write manual waits in Detox?
Rarely. Detox automatically waits until the app's JS thread, native UI, network requests, timers, and animations are idle before each action, so manual sleeps are an anti-pattern. The only time you write waitFor(...).withTimeout(...) is for state changes triggered by work Detox cannot observe, such as a websocket push or an external notification.
Why does my Detox test hang or time out forever?
The most common cause is an animation or timer that never settles — a looping spinner or a setInterval keeps the app perpetually busy, so Detox waits indefinitely. Run with --debug-synchronization to see which resource is busy, then either fix the animation, move it to the native driver, or temporarily wrap the region in device.disableSynchronization().
Can Detox run on both iOS and Android?
Yes. Detox supports iOS simulators and Android emulators (and real devices), with per-platform binaries and device entries defined in .detoxrc.js. iOS test builds require a macOS machine for both building and running; Android can build and run on Linux CI runners using a hardware-accelerated emulator.
What testIDs should I use for reliable selectors?
Add explicit, stable testID props to every interactive element and key text node you assert on, and select with by.id. Avoid by.text and by.label as primary selectors because they break under copy changes and localization. Keep test IDs descriptive and unique per screen so you rarely need atIndex().
How do I make Detox tests deterministic with network calls?
Replace real backends with a mock server or request interception in the test build so responses are fixed and fast. Detox tracks outstanding network requests for synchronization, so a hung real request will stall your test — controlling the network removes both the flakiness and the latency, and lets you assert error and edge-case states reliably.