detox-testing

Use whenever a React Native app needs end-to-end tests with Detox: gray-box runs inside the app, by.id, by.text and by.label matchers, waitFor for extra sync, and the Jest runner. Flutter is flutter-testing; cross-platform black-box is appium-testing; YAML flows are maestro-flows.

/plugin install qa-mobile@testland-qa

First time in Claude Code? Add the marketplace with /plugin marketplace add testland/qa

View source (opens in a new tab)

Detox's "gray-box" architecture is load-bearing: unlike Appium (black-box, external server), it runs in the app's process (per detox-docs (opens in new window)), so it "automatically monitors asynchronous operations to eliminate test flakiness at its core" (detox-docs (opens in new window)) - tracking network calls, animations, and timers without explicit sync hooks.

When to use

  • The app is React Native (the framework's intended use case).
  • Speed matters more than cross-stack reuse (Detox is faster than Appium for RN apps).
  • The team uses Jest (Detox's default runner; built-in integration).

If the app is native iOS/Android (not RN), use xcuitest-suite or espresso-suite. For non-RN-specific cross-platform, see appium-testing.

How to use

  1. Install Detox and scaffold with npx detox init (Step 1).
  2. Add testID props to the RN components the tests will target (Step 4).
  3. Build the app for the target device configuration with detox build (Step 2).
  4. Author e2e/*.test.js specs using element(by.id(...)) matchers, actions, and assertions (Step 3, references/element-api.md).
  5. Add waitFor(...).withTimeout(N) only where Detox's auto-sync misses a genuine async condition (Step 5).
  6. Run detox test --configuration <cfg> and iterate until green (Step 6).
  7. Wire build + test into CI as a headless job (Step 7).

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.debug

The build configuration in .detoxrc.js references the project's existing build commands (Gradle / xcodebuild) - Detox doesn't introduce a new build pipeline.

Verify: detox build must finish and emit the app binary before Step 6. If it fails, fix the underlying native build error (Gradle / xcodebuild) and re-run - the fix lives in the RN build config, not Detox.

Step 3 - Author tests with matchers

Match elements with element(by.id(...)); prefer by.id (the RN testID prop) for stable, translation-proof lookups. The full matcher catalog, combinators, action verbs, and assertions are in references/element-api.md.

Example 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 - 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 6 - 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 --headless

Verify: assert every spec reports green. If one fails, read the Detox error (element not found / timeout), fix the matcher or add a waitFor (Step 5), and re-run that spec before wiring CI. Detox then runs headless on CI platforms such as Travis CI, CircleCI, and Jenkins (per detox-docs (opens in new window)).

Step 7 - 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.debug

Worked example

A React Native store app needs an E2E check that adding a book to the cart updates the badge count.

  1. The product card already renders <TouchableOpacity testID="product-BOOK-001"> and the cart badge renders <Text testID="cart-count"> (Step 4).
  2. Build once: detox build --configuration ios.sim.debug (Step 2).
  3. Author e2e/cart.test.js with the 'adds item to cart' spec from Step 3.
  4. Run detox test e2e/cart.test.js --configuration ios.sim.debug (Step 6).
  5. Detox launches the simulator, taps the product and the add button, and its auto-sync waits for the state update before the assertion runs. The spec passes: the badge reads 1, with no sleep anywhere in the test.

Anti-patterns

Anti-patternWhy it failsFix
Querying by by.text for translatable stringsTranslation breaks tests.Use by.id (testID) for stable lookups (Step 3).
await sleep(2000) between actionsDetox's auto-sync is the point; sleeps mask real flake.waitFor(...) for genuine async; trust auto-sync otherwise.
Skipping device.reloadReactNative() between testsState 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 reinstallSlow; Detox's reload is faster.device.reloadReactNative() over device.launchApp().
Long-press without device wakeSimulator may be in screensaver; tap misses.device.shake() / device.openURL() to wake.

Limitations

  • React Native only. Native non-RN apps need xcuitest-suite or appium-testing.
  • iOS support depends on RN version. Per detox-docs (opens in new window), some iOS features may have caveats per RN version.
  • Test runner choice. Detox works with any runner; Jest is built-in. Mocha / Jasmine require additional config.
  • In-process gray-box trade-off. Detox knows about the JS bridge; native iOS/Android crashes that don't surface to JS may be invisible until next interaction.

References

  • det (opens in new window) - Detox overview: gray-box architecture, automatic async monitoring, JS test syntax, CI-ready, RN-specific.
  • references/element-api.md - matcher catalog (by.id, by.text, by.label, by.type, by.traits), combinators, actions, and assertions.
  • xcuitest-suite, espresso-suite, appium-testing, maestro-flows - alternatives.
Reference

Detox element API - matchers, actions, assertions

View source (opens in a new tab) (opens in new window)

Lookup reference for element(...) matchers, the actions callable on a matched element, and the assertions expect(...) supports. Referenced from Step 3 of the detox-testing skill.

Matchers

Per detox-matchers (opens in new window):

MatcherUse
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 (opens in new window):

withAncestor(matcher)    // child element within a parent
withDescendant(matcher)  // parent containing children
and(matcher)             // combine matchers
atIndex(index)           // when matcher returns multiple

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);

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.

  • Eleven green end-to-end runs while checkout was broken in every one of them
  • Nineteen specs went red the Monday we shipped German
  • A reinstall before every spec fixed the order-dependence and tripled the CI bill
  • The reorder spec cannot reach the row it needs, and the gesture it needs keeps getting eaten
  • The whole mobile suite froze the day pull-to-refresh landed, and the app is frozen too