Automate end-to-end instead of guessing
Anyone who has tested a React Native app with Appium or a Selenium-based tool knows the problem: sleeps, flaky assertions, tests that pass locally and fail in CI. Detox solves exactly that with a gray-box approach that synchronizes with the app's event loop instead of waiting blindly.
Table of contents
- 1. What sets Detox apart as an E2E testing framework
- 2. Setting up Detox: detox.config.js and .detoxrc.json
- 3. Writing test specs: element, expect and waitFor
- 4. Android: emulator configuration and instrumentation
- 5. iOS: simulator configuration and test target
- 6. CI integration with GitHub Actions
- 7. Avoiding flaky tests: causes and countermeasures
- 8. Native modules and mocking for test builds
- 9. Detox compared: Appium, Maestro and native frameworks
- 10. Summary
- 11. FAQ
1. What sets Detox apart as an E2E testing framework
Detox is an end-to-end testing framework built specifically for React Native, and it follows a fundamentally different approach than Appium- or Selenium-based tools. Classic UI automation is black-box: the test framework only knows what is visible on screen and has to work between actions with fixed or adaptive sleeps because it has no idea when the app has actually finished rendering. Detox instead follows a gray-box approach: a native module is embedded directly in the app and observes internal state, specifically the JavaScript event loop, in-flight network requests, active animations and timers.
This distinction is the actual reason why Detox E2E tests run so much more reliably in practice than comparable Appium suites. Instead of placing sleep(2000) between two actions and hoping the app is done by then, Detox actively waits until the app is truly idle before executing the next step of the test spec. That not only shortens the runtime of the test suite, because no unnecessary wait times pile up, it also eliminates the most common source of flakiness in mobile end-to-end automation: the race condition between a test step and a UI update that has not yet finished. Detox was originally built at Wix, precisely to solve this problem for their own React Native codebase at scale, and it is today the de facto standard tool for React Native E2E testing in the open source ecosystem.
2. Setting up Detox: detox.config.js and .detoxrc.json
Getting started with Detox begins with installing detox as a dev dependency and the global detox-cli, followed by a configuration file that traditionally lives as .detoxrc.json in the project root (or alternatively as detox.config.js, when dynamic logic is needed). This configuration defines three building blocks: apps describes how the app is built and located (binary path, build command), devices describes the target device (iOS simulator or Android emulator, each with type and API level), and configurations combines both into a named test run, for example android.emu.debug or ios.sim.release.
One crucial point when setting this up: Detox never tests against the debug build with live reload and the Chrome DevTools bridge, because that infrastructure distorts the timing characteristics of the app. Instead, a dedicated build type is used, often release-like, but with testing flags enabled so Detox can inject its synchronization logic without missing performance optimizations such as Hermes bytecode compilation. The test runner of choice is jest-circus, because unlike the classic Jasmine environment it plays cleanly with Detox's asynchronous lifecycle hooks, for example when restarting the app between test files.
{
"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/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",
"testBinaryPath": "android/app/build/outputs/apk/androidTest/debug/app-debug-androidTest.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"
}
}
}
3. Writing test specs: element, expect and waitFor
The test spec API of Detox is deliberately modeled on familiar patterns from Jest and Espresso, while staying compact. Elements are selected through matchers, most commonly by.id() against an explicit testID prop, but by.text() and by.type() are available too. Actions such as tap(), typeText() or scroll() are executed on the selected element, while expect() formulates assertions like toBeVisible(), toExist() or toHaveText(). The crucial difference from Appium: between every one of these actions Detox synchronizes automatically with the app, with no explicit wait code in the test itself.
For cases where automatic synchronization is not enough, for example navigation transitions with native animations or elements that only appear after a delayed network response, Detox provides waitFor() as an explicit building block. The combination waitFor(element(by.id("result"))).toBeVisible().withTimeout(5000) actively waits up to five seconds for the condition, but returns immediately as soon as it is met instead of exhausting the full timeout. That makes Detox E2E tests noticeably faster than classic polling loops with fixed intervals in the typical case.
// e2e/login.test.js: Detox spec for the login flow
describe("Login flow", () => {
beforeAll(async () => {
await device.launchApp({ newInstance: true });
});
beforeEach(async () => {
await device.reloadReactNative();
});
it("should show validation error on empty submit", async () => {
await element(by.id("login-submit-button")).tap();
await expect(element(by.id("email-error-label"))).toBeVisible();
});
it("should navigate to the dashboard after valid login", async () => {
await element(by.id("email-input")).typeText("qa@mironsoft.de");
await element(by.id("password-input")).typeText("s3cure-pass");
await element(by.id("login-submit-button")).tap();
// Detox synchronizes on the network request and the navigation transition
await waitFor(element(by.id("dashboard-title")))
.toBeVisible()
.withTimeout(8000);
await expect(element(by.text("Welcome back"))).toBeVisible();
});
it("should scroll to and tap the settings entry", async () => {
await waitFor(element(by.id("settings-menu-item")))
.toBeVisible()
.whileElement(by.id("dashboard-scroll-view"))
.scroll(200, "down");
await element(by.id("settings-menu-item")).tap();
await expect(element(by.id("settings-screen"))).toBeVisible();
});
});
4. Android: emulator configuration and instrumentation
On Android, Detox hooks into the app through an instrumented test, technically through a dedicated test runner that extends AndroidJUnitRunner and has to be registered as testInstrumentationRunner in android/app/build.gradle. This runner initializes the native Detox library before the app starts, and makes sure that the synchronization logic for the UI thread, the network layer (an OkHttp interceptor) and the React Native bridge is active. Without this entry, Detox can still find the app but cannot synchronize with its internal state, and effectively falls back to blind waiting.
For the Android emulator in Detox end-to-end runs, hardware acceleration (KVM on Linux, HAXM or the Hypervisor framework on macOS) is practically mandatory, since a purely software-emulated device slows the test suite down by a large factor and often causes timeouts in CI environments. The AVD name should match exactly what is configured in .detoxrc.json, with animations disabled (window animation scale, transition animation scale and animator duration scale all set to 0), because while Detox synchronizes many native animations reliably, it does not reliably detect every custom animation from third-party libraries.
// android/app/src/androidTest/java/com/myapp/DetoxTestRunner.kt
package com.myapp
import android.app.Application
import android.content.Context
import androidx.test.runner.AndroidJUnitRunner
import com.wix.detox.Detox
import com.wix.detox.config.DetoxConfig
/**
* Custom instrumentation runner required by Detox to hook into the
* app process before the React Native bridge is created.
*/
class DetoxTestRunner : AndroidJUnitRunner() {
override fun onCreate(arguments: android.os.Bundle) {
val detoxConfig = DetoxConfig()
detoxConfig.idlePolicyConfig.masterTimeoutSec = 90
detoxConfig.idlePolicyConfig.idleResourceTimeoutSec = 60
detoxConfig.rnContextLoadTimeoutSec = if (BuildConfig.DEBUG) 180 else 60
Detox.runTests(this, detoxConfig)
super.onCreate(arguments)
}
override fun newApplication(cl: ClassLoader, className: String, context: Context): Application {
return super.newApplication(cl, MainApplication::class.java.name, context)
}
}
5. iOS: simulator configuration and test target
On iOS, Detox relies on a separate test target in the Xcode project, built with xcodebuild build-for-testing, which links the native Detox synchronization library pulled in as a CocoaPods Detox pod. Unlike purely UI-driven frameworks such as XCUITest, Detox here reaches deep into the run loop of the main thread, into active URLSession tasks and into the timer scheduler to detect when the app has genuinely come to rest. This integration requires that the debug build accept the Detox server launch parameter and establish a connection to the local Detox test runner on startup.
The device name in .detoxrc.json must match exactly an installed iOS simulator profile, created beforehand via xcrun simctl or verified through Xcode. For app permissions, for example camera, location or push notifications, one uses device.launchApp({ permissions: { notifications: "YES" } }) so the system dialog never appears in the first place and blocks the end-to-end test run. For deep-linking tests, Detox additionally supports device.openURL(), which can be tested reliably in combination with a URL scheme handler registered in the app delegate.
// ios/MyApp/AppDelegate.swift: Detox launch-argument handling (debug builds only)
import UIKit
#if DEBUG
import Detox
#endif
@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
var window: UIWindow?
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
#if DEBUG
// Only active when the app is launched by the Detox test runner
if ProcessInfo.processInfo.arguments.contains("-detoxServer") {
Detox.setup(scheme: nil, launchArgs: ProcessInfo.processInfo.arguments)
}
#endif
// Regular React Native bootstrap continues here
return true
}
}
6. CI integration with GitHub Actions
The biggest practical hurdle for Detox E2E testing in CI is not Detox itself but the infrastructure underneath it. iOS simulators only run on macOS runners, Android emulators with hardware acceleration need a Linux runner with KVM access, usually through the reactivecircus/android-emulator-runner action, which boots and caches the AVD in advance so not every run suffers a cold emulator start. The raw build time of Xcode and Gradle projects frequently exceeds the actual test runtime in CI, which is why caching pods, the Gradle wrapper and node modules is the biggest lever for fast pipelines.
The actual test run splits into two separate CLI steps: detox build compiles the app and the instrumentation APK, or the iOS test target, and detox test installs both on the target device and runs the Jest specs. This separation lets teams share the build artifact across several parallel test runs, which saves noticeable time in large React Native E2E suites sharded across multiple runners. On failure, Detox should be configured to archive screenshots, videos and device logs as artifacts, because a plain stack trace rarely provides enough context for debugging UI tests.
#!/usr/bin/env bash
# CI steps for Detox end-to-end tests (invoked from a GitHub Actions job)
set -euo pipefail
# Android: build the app and the instrumentation test APK
detox build --configuration android.emu.release
# Android: boot the pre-cached emulator and run the Jest specs
detox test \
--configuration android.emu.release \
--cleanup \
--headless \
--record-videos failing \
--record-logs failing \
--artifacts-location artifacts/android
# iOS: build the app and the test target without running yet
detox build --configuration ios.sim.release
# iOS: boot the simulator and run the same spec suite
detox test \
--configuration ios.sim.release \
--cleanup \
--record-videos failing \
--artifacts-location artifacts/ios
echo "Detox end-to-end suite finished, artifacts stored under artifacts/"
7. Avoiding flaky tests: causes and countermeasures
Even with gray-box synchronization, Detox is not automatically immune to flakiness, because not every source of non-determinism lives on the UI thread. Custom animation libraries that do not run through React Native's standard Animated API often fail to register with Detox's idle tracking, which means a test keeps running while an animation is still visually active. Detox also deliberately ignores long setTimeout calls above an internal threshold, because otherwise any background timer would block the test run indefinitely, which in practice means: anyone waiting on a delayed UI state still needs an explicit waitFor().
The most effective countermeasures are structural rather than symptomatic. Animations are disabled globally in the test build, both at the app and the operating system level, network calls are made deterministic through a mock server or device.setURLBlacklist(), and every test starts with device.reloadReactNative() in a clean, isolated app state instead of building on the result of the previous test. Stable testID values that do not change between renders matter just as much as avoiding text-based matchers in multilingual apps, since by.text() breaks with every localization change. In practice, this discipline determines the stability of end-to-end tests far more than the choice of framework itself.
8. Native modules and mocking for test builds
Real hardware dependencies such as push notifications, biometrics, in-app purchases or camera access cannot be meaningfully tested against real backends or real sensors in a CI pipeline, even with Detox's synchronization capabilities. The established approach is a dedicated test build flavor in which native modules are replaced with in-memory implementations: on Android through a separate product flavor with a stub implementation of the relevant bridge class, on iOS through a conditionally compiled Swift file that is only linked into the Detox test target.
On the JavaScript side, this native mocking is complemented by Jest module mocks for libraries that access native APIs synchronously, along with a mock HTTP layer that replaces real backend responses with fixed fixtures. That keeps the actual Detox end-to-end test focused on what it should really verify: the interplay of UI, navigation and state management in the React Native app, without depending on the availability of external services or physical hardware. This separation also makes the test suite runnable locally for developers without access to production credentials.
9. Detox compared: Appium, Maestro and native frameworks
Choosing the right E2E testing tool for React Native depends heavily on how much control a team has over native build configuration and how much weight test speed carries against setup effort. Detox requires more upfront configuration work than, say, Maestro, but delivers the lowest flaky rate in direct comparison, because no other cross-platform framework synchronizes as deeply with the React Native runtime.
| Criterion | Detox | Appium | Maestro | XCUITest/Espresso |
|---|---|---|---|---|
| Synchronization model | gray-box, event-loop-based | black-box, polling/sleeps | black-box, adaptive sleeps | native, platform-specific |
| Platform coverage | iOS + Android, one test codebase | iOS + Android, one test codebase | iOS + Android, one test codebase | single platform each |
| Setup effort | high (native build configuration) | high (Appium server, drivers) | low (YAML flows) | medium (Xcode/Gradle built-in) |
| Typical flakiness | low | high | medium | low |
| CI integration effort | medium to high | high | low | medium |
Appium remains relevant when a test suite has to cover native and hybrid apps through a single API at the same time, but it pays for that with a higher flaky rate and slower runs. Maestro scores with minimal setup and declarative YAML flows, but offers less control for complex assertions and deep debugging than Detox. Anyone testing React Native exclusively and prioritizing stable, fast end-to-end tests in CI will find it hard to bypass Detox as the reference implementation.
Mironsoft
React Native development, test automation and CI/CD pipelines
Running a React Native app without stable E2E tests?
We set up Detox test suites for your React Native app, including detox.config.js, Android and iOS configuration, and a robust GitHub Actions pipeline without flaky tests.
Detox setup
detox.config.js, Android instrumentation and iOS test target configured from scratch
Test spec design
Maintainable element/expect/waitFor specs for your critical user flows
CI integration
GitHub Actions pipeline with emulator caching and artifact reporting on failure
10. Summary
Detox solves the fundamental problem of classic mobile E2E testing: instead of working blindly with sleeps, it synchronizes with the app's event loop, network layer and timers through a native module, and waits exactly as long as the app actually needs. detox.config.js, or .detoxrc.json, defines apps, devices and configurations, while test specs with element(), expect() and waitFor() stay readable and compact. Android needs a dedicated instrumentation runner, iOS a separate test target with the Detox pod linked in.
In CI, separating detox build from detox test pays off, complemented by emulator caching, disabled animations and network mocking against flakiness. Native modules such as push, biometrics or camera are replaced with stub implementations for test builds, so the end-to-end test verifies only app behavior, not the availability of external services. Teams that apply these points consistently end up with a Detox suite that actually runs reliably in every pipeline, not just occasionally.
React Native E2E Testing with Detox: Key Takeaways
Gray-box synchronization
A native module watches the event loop, network and timers and waits exactly until the app is idle, no blind sleeps.
Test specs with element/expect/waitFor
by.id, tap, typeText and toBeVisible form compact, readable end-to-end specs for critical user flows.
Android & iOS setup
A dedicated instrumentation runner on Android, a separate test target with the Detox pod on iOS.
CI & flaky-test mitigation
detox build/test kept separate, emulator caching, disabled animations and mocked native modules in GitHub Actions.