detox-testing
Authors React Native E2E tests using Detox (Wix) - uses gray-box architecture (test runs in-process with the app), `element(by.id|by.text|by.label)` matchers, `waitFor()` for explicit synchronization beyond Detox's automatic async tracking, and Jest as the default test runner. Use when the app is React Native and the team wants the fastest / most-reliable RN-specific framework.
detox-testing
The "gray-box" distinction is load-bearing: unlike Appium (black-box, external server), Detox runs in the app's process (per detox-docs). This gives Detox direct access to the JS bridge for automatic synchronization:
"The framework automatically monitors asynchronous operations to eliminate test flakiness at its core." (detox-docs)
Detox knows when network calls finish, when animations settle, when timers fire - without explicit IdlingResource-style hooks.
When to use
If the app is native iOS/Android (not RN), use xcuitest-suite or espresso-suite. For non-RN-specific cross-platform, see appium-testing.
Step 1 - Install
npm install --save-dev detox
npx detox init # scaffolds .detoxrc.js + e2e/ directory.detoxrc.js configures device + app + runner; the default template is sensible.
Step 2 - Build the app for testing
# Android
detox build --configuration android.emu.debug
# iOS
detox build --configuration ios.sim.debugThe build configuration in .detoxrc.js references the project's existing build commands (Gradle / xcodebuild) - Detox doesn't introduce a new build pipeline.
Step 3 - Author tests with matchers
Per detox-matchers:
| Matcher | Use |
|---|---|
by.id('tap_me') | React Native testID prop (preferred default). |
by.text('Tap Me') | Visible text content. |
by.label('...') | iOS accessibility label / Android content description. |
by.type('RCTImageView') | Component class name (iOS / Android-specific). |
by.traits(['button']) | iOS only - accessibility traits. |
Each accepts strings or regex (by.id(/^tap_[a-z]+$/)).
Combinators per detox-matchers:
withAncestor(matcher) // child element within a parent
withDescendant(matcher) // parent containing children
and(matcher) // combine matchers
atIndex(index) // when matcher returns multipleExample test:
describe('Cart flow', () => {
beforeAll(async () => {
await device.launchApp();
});
beforeEach(async () => {
await device.reloadReactNative();
});
it('adds item to cart', async () => {
await element(by.id('product-BOOK-001')).tap();
await element(by.id('add-to-cart-button')).tap();
await expect(element(by.id('cart-count'))).toHaveText('1');
});
it('applies promo code', async () => {
await element(by.id('promo-input')).typeText('WELCOME10');
await element(by.id('apply-promo-button')).tap();
await expect(element(by.id('subtotal'))).toHaveText('$22.49');
});
});Step 4 - Production code: set testID
In RN production code:
<TouchableOpacity testID="add-to-cart-button" onPress={addToCart}>
<Text>Add to cart</Text>
</TouchableOpacity>
<TextInput testID="promo-input" value={code} onChangeText={setCode} />testID is React Native's prop for accessibilityIdentifier (iOS) / resource-id (Android). Detox finds elements by it.
Step 5 - Actions
await element(by.id('btn')).tap();
await element(by.id('btn')).longPress();
await element(by.id('btn')).multiTap(2);
await element(by.id('input')).typeText('hello');
await element(by.id('input')).clearText();
await element(by.id('input')).replaceText('new text');
await element(by.id('list')).scroll(200, 'down');
await element(by.id('list')).scrollTo('bottom');
await element(by.id('list')).swipe('left', 'fast');
await element(by.id('toggle')).pinch(1.5);Step 6 - Assertions
await expect(element(by.id('toast'))).toBeVisible();
await expect(element(by.id('cart-count'))).toHaveText('1');
await expect(element(by.id('error'))).not.toBeVisible();
await expect(element(by.id('field'))).toHaveValue('expected');expect(...) from Detox auto-waits up to a default timeout (typically 5s) - no explicit waitFor needed for normal synchronization-tracked work.
Step 7 - waitFor for explicit sync
When Detox's automatic tracking misses something:
await waitFor(element(by.id('async-result')))
.toBeVisible()
.withTimeout(10000);
await waitFor(element(by.id('progress-bar')))
.not.toBeVisible()
.whileElement(by.id('list')).scroll(100, 'down');waitFor(...).withTimeout(N) polls the condition for up to N ms. whileElement(...) performs an action (scroll) while waiting - useful for "scroll until visible."
Step 8 - Run
detox test --configuration android.emu.debug
detox test --configuration ios.sim.debug
# Specific test file
detox test e2e/cart.test.js --configuration ios.sim.debug
# Headless mode
detox test --headlessPer detox-docs, Detox is "CI-Ready: Tests execute seamlessly on continuous integration platforms like Travis CI, CircleCI, and Jenkins."
Step 9 - CI integration
jobs:
detox-android:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- run: npm ci
- uses: reactivecircus/android-emulator-runner@v2
with:
api-level: 34
script: |
detox build --configuration android.emu.debug
detox test --configuration android.emu.debug --headless
detox-ios:
runs-on: macos-15
steps:
- uses: actions/checkout@v5
- run: npm ci
- run: npx pod-install
- run: |
detox build --configuration ios.sim.debug
detox test --configuration ios.sim.debugAnti-patterns
| Anti-pattern | Why it fails | Fix |
|---|---|---|
Querying by by.text for translatable strings | Translation breaks tests. | Use by.id (testID) for stable lookups (Step 3). |
await sleep(2000) between actions | Detox's auto-sync is the point; sleeps mask real flake. | waitFor(...) for genuine async; trust auto-sync otherwise. |
Skipping device.reloadReactNative() between tests | State leaks; tests pollute each other. | beforeEach reload (Step 3 example). |
by.type('RCTView') (iOS-specific class) | Tests break on Android (different class name). | Use by.id (cross-platform) or platform-conditional code. |
| Per-test app reinstall | Slow; Detox's reload is faster. | device.reloadReactNative() over device.launchApp(). |
| Long-press without device wake | Simulator may be in screensaver; tap misses. | device.shake() / device.openURL() to wake. |