🚀 End-to-End Testing a Production React Native App with Detox: A Complete Guide

How I built a robust, cross-platform E2E test suite — covering login flows, multi-step navigation, and real payment gateway integrations in a React Native application.



---


If you've ever shipped a mobile app and prayed nothing broke between the login screen and the checkout page, this blog is for you.


I spent weeks building a comprehensive Detox E2E test suite for a production React Native app that handles real payments through Apple Pay, Google Pay, PayPal, and Credit Cards via Braintree. This post documents every single detail — from initial setup to the hard-won lessons that no documentation tells you about.


By the end, you'll have a copy-paste-ready blueprint for your own project.


---


## Why Detox Over Everything Else?


Before writing a single test, I evaluated Appium, Maestro, and Detox. Here's why Detox won:


| Criteria | Detox | Appium | Maestro |

|---|---|---|---|

| **Gray-box testing** | ✅ Knows about React Native bridge | ❌ Black-box only | ❌ Black-box only |

| **Synchronization** | ✅ Auto-waits for animations, network, timers | ❌ Manual sleeps everywhere | ⚠️ Partial |

| **Speed** | ✅ Runs on simulator/emulator directly | ❌ WebDriver protocol overhead | ✅ Fast |

| **React Native optimized** | ✅ Built by Wix specifically for RN | ❌ Generic | ❌ Generic |

| **TypeScript support** | ✅ First-class | ⚠️ Via wrappers | ❌ YAML-based |

| **CI/CD integration** | ✅ Jest-based, standard pipelines | ✅ | ✅ |


The killer feature is **grey-box synchronisation**. Detox hooks into the React Native bridge and actually *knows* when your app is idle — no pending animations, no running timers, no in-flight network requests. This eliminates the `sleep(3000)` anti-pattern that plagues every black-box testing tool.


In simple terms: Detox waits intelligently, so you don't have to guess.
Official doc:- https://wix.github.io/Detox/




---


## What We're Working With


Here's the tech stack I used. Your versions may differ, but the concepts apply broadly:


```

React Native:         0.81.0

React:                19.1.0

Detox:                ^20.0.0

Jest:                 ^29.6.3

TypeScript:           5.8.3

Node.js:              >= 18

```


### Before You Start — Global Installs


You'll need these installed on your machine:


```bash

# Detox CLI (the command-line tool you'll use to build and run tests)

npm install -g detox-cli


# iOS: required simulator utilities (macOS only)

brew tap wix/brew

brew install applesimutils


# Android: make sure these environment variables are set

export ANDROID_HOME=$HOME/Library/Android/sdk

export JAVA_HOME=$(/usr/libexec/java_home)

```


---


## Setting Up Detox from Scratch


### Step 1: Install Detox


```bash

npm install --save-dev detox

```


### Step 2: Add Convenience Scripts to `package.json`


```json

{

  "scripts": {

    "detox:build:ios": "detox build --configuration ios.sim.debug",

    "detox:build:android": "detox build --configuration android.emu.debug",

    "detox:test:ios": "detox test --configuration ios.sim.debug",

    "detox:test:android": "detox test --configuration android.emu.debug"

  }

}

```


Four scripts. That's all you'll use day-to-day — build creates the binary, test runs the suite.


### Step 3: Create the E2E Config Directory


```bash

mkdir e2e

```


---


## The Detox Configuration File — Every Line Explained


Create a `.detoxrc.js` file in your project root. This is the brain of your Detox setup. I'm going to walk through every single key because each one matters:


```javascript

/** @type {Detox.DetoxConfig} */

module.exports = {


  // ═══════════════════════════════════════════════════════════════

  // TEST RUNNER — tells Detox which test framework to use

  // ═══════════════════════════════════════════════════════════════

  testRunner: {

    args: {

      $0: 'jest',                      // Use Jest as the test runner (Detox default)

      config: 'e2e/jest.config.js',    // Path to Detox-specific Jest config

    },

    detox: {

      bail: false,                     // false = run ALL tests, even if one fails

                                       // (in CI you want to see EVERY failure)

    },

  },


  // ═══════════════════════════════════════════════════════════════

  // APPS — defines how to build each platform's binary

  // ═══════════════════════════════════════════════════════════════

  apps: {

    "ios.debug": {

      type: "ios.app",                 // Tells Detox this is an iOS .app bundle

      binaryPath:                      // Where the compiled .app lives after build

        "ios/build/Build/Products/Debug-iphonesimulator/YourApp.app",

      build:                           // The actual xcodebuild command to compile it

        "xcodebuild -workspace ios/YourApp.xcworkspace -scheme YourApp -configuration Debug -sdk iphonesimulator -derivedDataPath ios/build"

    },

    'ios.release': {

      type: 'ios.app',

      binaryPath: 'ios/build/Build/Products/Release-iphonesimulator/YourApp.app',

      build: 'xcodebuild -workspace ios/YourApp.xcworkspace -scheme YourApp -configuration Release -sdk iphonesimulator -derivedDataPath ios/build',

    },

    'android.debug': {

      type: 'android.apk',            // Tells Detox this is an Android .apk

      binaryPath:                      // Path to the compiled app APK

        'android/app/build/outputs/apk/debug/app-debug.apk',

      testBinaryPath:                  // Android-ONLY: a second APK containing the

                                       // Detox instrumentation test runner

        'android/app/build/outputs/apk/androidTest/debug/app-debug-androidTest.apk',

      build:                           // Gradle command — builds BOTH the app and test APK

        'cd android && ./gradlew assembleDebug assembleDebugAndroidTest -DtestBuildType=debug',

      reversePorts: [8081],            // Android emulators can't reach localhost:8081

                                       // (Metro bundler). This uses adb reverse to

                                       // tunnel the port automatically.

    },

    'android.release': {

      type: 'android.apk',

      binaryPath: 'android/app/build/outputs/apk/release/app-release.apk',

      testBinaryPath: 'android/app/build/outputs/apk/androidTest/release/app-release-androidTest.apk',

      build: 'cd android && ./gradlew assembleRelease assembleReleaseAndroidTest -DtestBuildType=release',

    },

  },


  // ═══════════════════════════════════════════════════════════════

  // DEVICES — which simulator/emulator/physical device to run on

  // ═══════════════════════════════════════════════════════════════

  devices: {

    simulator: {

      type: 'ios.simulator',           // iOS Simulator

      device: {

        type: 'iPhone 17 Pro',         // Must match an installed simulator

      },

    },

    attached: {

      type: 'android.attached',        // Physically connected Android device

      device: {

        adbName: '.*',                 // Regex — matches ANY attached device

      },

    },

    emulator: {

      type: 'android.emulator',        // Android Studio emulator

      device: {

        avdName: 'Pixel_9_Pro',        // Must match an AVD name in Android Studio

      },

    },

  },


  // ═══════════════════════════════════════════════════════════════

  // CONFIGURATIONS — maps a name → (device + app) pair

  // This is what you pass to --configuration when running tests

  // ═══════════════════════════════════════════════════════════════

  configurations: {

    'ios.sim.debug': {

      device: 'simulator',            // Use the 'simulator' device defined above

      app: 'ios.debug',               // Use the 'ios.debug' app defined above

    },

    'ios.sim.release': {

      device: 'simulator',

      app: 'ios.release',

    },

    'android.emu.debug': {

      device: 'emulator',

      app: 'android.debug',

    },

    'android.emu.release': {

      device: 'emulator',

      app: 'android.release',

    },

  },

};

```


> 💡 **If you use Android product flavors** (like `Development`, `Production`), your Gradle commands and binary paths need to include the flavor name. For example: `assembleDevelopmentDebug` instead of just `assembleDebug`.


---


## Jest Configuration for Detox


Create `e2e/jest.config.js`:


```javascript

/** @type {import('@jest/types').Config.InitialOptions} */

module.exports = {

  // rootDir points to the project root (one level up from e2e/)

  rootDir: '..',


  // Where Jest looks for test files

  testMatch: [

    '<rootDir>/e2e/**/*.test.js',      // Tests in the e2e/ directory

    '<rootDir>/src/**/*.test.ts',      // Tests co-located with source files

  ],


  // 5 minutes per test — sounds extreme, but payment flows involving

  // external gateways (PayPal redirects, 3D Secure) can take 30+ seconds.

  // A generous timeout prevents false negatives on slow networks.

  testTimeout: 300000,


  // MUST be 1. Detox controls a single device at a time.

  // Parallel workers would cause device conflicts.

  maxWorkers: 1,


  // These three are Detox built-ins. They handle:

  // - globalSetup: launching the Detox server

  // - globalTeardown: cleanup after all suites

  // - testEnvironment: injecting device, element, by, expect, waitFor

  //   into every test file as globals

  globalSetup: 'detox/runners/jest/globalSetup',

  globalTeardown: 'detox/runners/jest/globalTeardown',

  testEnvironment: 'detox/runners/jest/testEnvironment',


  // Print individual test results with their names

  verbose: true,

};

```


---


## Android-Specific Setup


Android requires a bit more wiring than iOS. There are two things you need.


### 1. The Kotlin Test Runner


Create this file at `android/app/src/androidTest/java/com/yourpackage/DetoxTest.kt`:


```kotlin

package com.yourpackage


import com.wix.detox.Detox

import com.wix.detox.config.DetoxConfig


import org.junit.Rule

import org.junit.Test

import org.junit.runner.RunWith


import androidx.test.ext.junit.runners.AndroidJUnit4

import androidx.test.rule.ActivityTestRule

import androidx.test.filters.LargeTest


// This class is the bridge between Android's test infrastructure

// and Detox's Node.js test runner. Android needs a native entry

// point for instrumented tests — this is it.


@RunWith(AndroidJUnit4::class)   // Use the AndroidX JUnit4 runner

@LargeTest                       // Marks this as a long-running test

class DetoxTest {


    // ActivityTestRule tells Android which Activity to launch.

    // The (false, false) means:

    //   1st false = don't launch in initial touch mode

    //   2nd false = don't auto-launch — let Detox control when the Activity starts

    @get:Rule

    val activityRule = ActivityTestRule(MainActivity::class.java, false, false)


    @Test

    fun runDetoxTests() {

        val detoxConfig = DetoxConfig()


        // Maximum time (seconds) Detox waits for the app to become idle.

        // Payment SDK initialization can take a while — 90s gives headroom.

        detoxConfig.idlePolicyConfig.masterTimeoutSec = 90


        // Time to wait for individual idle resources (animations, timers).

        detoxConfig.idlePolicyConfig.idleResourceTimeoutSec = 60


        // In Debug mode, the JS bundle loads over the network from Metro

        // and can take up to 3 minutes on slow machines.

        // In Release mode (pre-bundled), 60 seconds is plenty.

        detoxConfig.rnContextLoadTimeoutSec = if (BuildConfig.DEBUG) 180 else 60


        // Hand control to Detox — it communicates with the Node.js

        // test runner via gRPC from this point.

        Detox.runTests(activityRule, detoxConfig)

    }

}

```


### 2. Gradle Configuration


Add these to your `android/app/build.gradle`:


```groovy

android {

    defaultConfig {

        // Tells Android which build type the test APK targets.

        // The -DtestBuildType=debug flag from .detoxrc.js flows in here.

        testBuildType System.getProperty('testBuildType', 'debug')


        // The AndroidX JUnit runner — entry point for instrumented tests.

        // Detox bootstraps itself from within this runner.

        testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner'

    }


    buildTypes {

        release {

            // If you run Detox against Release (ProGuarded) builds,

            // you MUST include Detox's ProGuard rules. Otherwise

            // ProGuard strips classes that Detox needs at runtime.

            proguardFile "${rootProject.projectDir}/../node_modules/detox/android/detox/proguard-rules-app.pro"

        }

    }

}


dependencies {

    // The Detox Android library — contains native synchronization hooks,

    // gRPC communication layer, and element matching engine.

    androidTestImplementation('com.wix:detox:+')

}

```


---


## Making Your App Testable: The `testID` Convention


This is the single most important architectural decision for E2E testing. Every element you need to interact with or assert on **must** have a `testID` prop in your React Native components.


### My Naming Convention


I use the pattern `{screen}_{element}`:


| Pattern | Example | Used For |

|---|---|---|

| `{screen}_pageTitle` | `login_pageTitle` | Page headers |

| `{screen}_{action}Button` | `login_submitButton` | Buttons |

| `{screen}_{field}Input` | `login_emailInput` | Text inputs |

| `{screen}_{name}Toggle` | `settings_darkModeToggle` | Toggle switches |

| `{screen}_{name}Link` | `login_forgotPasswordLink` | Links |

| `{screen}_{entity}Card` | `checkout_savedCard` | Cards |

| `{screen}_{name}Option` | `shipping_expressOption` | Selections |

| `tabBar_{TAB_NAME}` | `tabBar_HOME`, `tabBar_CART` | Tab bar buttons |

| `{screen}_scrollView` | `checkout_scrollView` | ScrollViews |


### How It Looks in Practice


In your component, simply pass `testID` as a prop:


```tsx

// A simple Button component

<TouchableOpacity testID={testID} onPress={onPress}>

  <Text>{title}</Text>

</TouchableOpacity>

```


For dynamically generated elements (like tab bars):


```tsx

{routes.map((route, index) => (

  <TouchableWithoutFeedback

    key={index}

    testID={`tabBar_${route.name}`}    // Produces: tabBar_HOME, tabBar_CART, etc.

    onPress={() => navigation.navigate(route.name)}

  >

    {/* ... */}

  </TouchableWithoutFeedback>

))}

```


Under the hood, `testID` maps to `accessibilityIdentifier` on iOS and the view tag on Android. Detox matches against these with `by.id()`, giving you a stable selector that survives UI redesigns. Unlike XPath or text-based selectors, `testID` won't break when you rename a button from "Submit" to "Confirm."


---


## Writing Your First Test: A Step-by-Step Login Example


Now for the fun part. Let me walk you through a complete login test suite, explaining **every single line** so you know exactly what's happening and why.


### Test File Setup


```typescript

// ─── IMPORTS ──────────────────────────────────────────────────────────────────

// Detox provides these as globals via the testEnvironment,

// but importing them explicitly gives you TypeScript autocompletion.

import { by, device, element, expect, waitFor } from 'detox';

//

//   by       → element locator strategies (by.id, by.text, by.type, etc.)

//   device   → controls the device/simulator (launch, reload, permissions)

//   element  → creates an element reference for interactions (tap, type, scroll)

//   expect   → assertion engine (toBeVisible, toExist, toHaveText)

//   waitFor  → polling-based waiter (keeps checking until condition is met or timeout)

```


### The `describe` Block and Lifecycle Hooks


```typescript

describe('Login Flow', () => {


  // ─── TEST DATA ────────────────────────────────────────────────────────────

  // Define credentials as constants at the top.

  // Never hardcode them inline — makes them easy to find and update.

  const VALID_EMAIL = 'testuser@example.com';

  const VALID_PASSWORD = 'SecureP@ss123!';

  const WRONG_PASSWORD = 'TotallyWrong456!';


  // ─── beforeAll ────────────────────────────────────────────────────────────

  // Runs ONCE before all tests in this describe block.

  // This is where you do expensive, one-time setup.

  beforeAll(async () => {

    await device.launchApp({

      newInstance: true,     // Kill any running instance of the app first.

                             // Ensures no leftover state from a previous run.


      delete: true,          // Wipe ALL app data — AsyncStorage, Keychain,

                             // EncryptedStorage, SQLite, everything.

                             // Guarantees a completely clean slate.


      permissions: {         // Pre-grant system permissions so the OS dialog

        notifications: 'YES' // doesn't pop up and block our tests.

                             // (System dialogs are outside the app — Detox

                             // can't dismiss them with by.id().)

      },

    });

  });


  // ─── afterAll ─────────────────────────────────────────────────────────────

  // Runs ONCE after all tests in this describe block complete.

  afterAll(async () => {

    // Intentionally NOT calling device.terminateApp().

    // Why? If we have another test suite that runs after this one

    // (e.g., a Checkout test), it can reuse the authenticated session

    // instead of logging in again. Saves ~30 seconds per suite.

  });


  // ─── beforeEach ───────────────────────────────────────────────────────────

  // Runs BEFORE every single test (it-block).

  beforeEach(async () => {

    await device.reloadReactNative();

    // reloadReactNative() reloads ONLY the JavaScript bundle.

    // It does NOT kill the native app process.

    // This is ~3x faster than device.launchApp() and resets all React

    // state (Redux store, component state, navigation) while preserving

    // native state (stored credentials, permissions).

    //

    // Result: every test starts from the app's initial screen (e.g., Welcome)

    // with a fresh React tree.

  });

```


### Scenario 1: Smoke Test — Does the Screen Render?


```typescript

  it('should display all elements on the Welcome screen', async () => {

    // ── expect().toBeVisible() ──────────────────────────────────────────

    // Asserts that the element is currently visible within the viewport.

    // If the element exists but is scrolled off-screen, this FAILS.

    // Use toExist() instead for off-screen elements.


    await expect(element(by.id('welcome_loginButton'))).toBeVisible();

    //                  

    //                   └─ by.id('welcome_loginButton')

    //                     Finds an element whose testID prop equals

    //                     'welcome_loginButton'. This is the primary

    //                     way to locate elements in Detox.

    //          

    //           └─ element(...)

    //               Creates a reference to the matching element.

    //               Does NOT interact with it yet — just identifies it.

    //    

    //     └─ expect(...)

    //        Wraps the element reference in an assertion context.

    //        Allows chaining with .toBeVisible(), .toExist(), .toHaveText(), etc.


    await expect(element(by.id('welcome_signupButton'))).toBeVisible();

    await expect(element(by.id('welcome_guestLink'))).toBeVisible();

    await expect(element(by.id('welcome_heroImage'))).toBeVisible();

  });

```


### Scenario 2: Navigation — Tapping Through to the Login Form


```typescript

  it('should navigate to login screen and show all form elements', async () => {


    // ── element().tap() ─────────────────────────────────────────────────

    // Simulates a user tap on the element.

    // Detox will first scroll the element into view if needed,

    // then perform a native touch event at its center point.

    await element(by.id('welcome_loginButton')).tap();


    // ── waitFor().toBeVisible().withTimeout() ───────────────────────────

    // Polls every ~100ms checking if the element becomes visible.

    // If it does within the timeout, the test continues.

    // If it doesn't, the test FAILS with a timeout error.

    //

    // Why use waitFor instead of expect?

    // After tapping the login button, there's a navigation animation.

    // The Login screen takes ~300-500ms to fully render. If we used

    // expect() immediately, it would fail because the element isn't

    // there yet. waitFor gives it time to appear.

    await waitFor(element(by.id('login_pageTitle')))

      .toBeVisible()

      .withTimeout(5000);     // 5 seconds — generous enough for slow devices


    // Once we've confirmed the screen loaded, we can use regular

    // expect() for elements that should already be rendered.

    await expect(element(by.id('login_emailInput'))).toBeVisible();

    await expect(element(by.id('login_passwordInput'))).toBeVisible();

    await expect(element(by.id('login_rememberMeToggle'))).toBeVisible();

    await expect(element(by.id('login_submitButton'))).toBeVisible();

    await expect(element(by.id('login_forgotPasswordLink'))).toBeVisible();

  });

```


### Scenario 3: Form Validation — Submit Empty Fields


```typescript

  it('should show validation errors when submitting empty fields', async () => {

    await element(by.id('welcome_loginButton')).tap();


    await waitFor(element(by.id('login_pageTitle')))

      .toBeVisible()

      .withTimeout(5000);


    // Tap submit WITHOUT entering any data.

    await element(by.id('login_submitButton')).tap();


    // Assert: we should still be on the Login screen (not navigated away).

    // Client-side validation should have caught the empty fields.

    await expect(element(by.id('login_pageTitle'))).toBeVisible();


    // If your app shows inline error messages:

    // await expect(element(by.id('login_emailError'))).toBeVisible();

    // await expect(element(by.id('login_passwordError'))).toBeVisible();

  });

```


### Scenario 4: Invalid Credentials — API Error Handling


```typescript

  it('should show an error message with wrong credentials', async () => {

    await element(by.id('welcome_loginButton')).tap();


    // ── clearText() ─────────────────────────────────────────────────────

    // Clears any existing text in the input field.

    // Always call this BEFORE typeText/replaceText, even if the field

    // should be empty. This is defensive programming — it eliminates

    // flakes caused by autofill, state restoration, or test order bugs.

    await element(by.id('login_emailInput')).clearText();


    // ── typeText() ──────────────────────────────────────────────────────

    // Types characters one by one, triggering onChangeText for each

    // keystroke. This is the most realistic simulation of user typing.

    // Note: this also opens the software keyboard.

    await element(by.id('login_emailInput')).typeText(VALID_EMAIL);


    await element(by.id('login_passwordInput')).clearText();


    // ── replaceText() ───────────────────────────────────────────────────

    // Sets the entire text value at once (atomically).

    // Unlike typeText(), it does NOT type character by character.

    //

    // When to use replaceText() over typeText():

    //   • Password fields with secureTextEntry — typeText can cause

    //     visual flicker with the masking animation

    //   • When per-character onChangeText callbacks aren't important

    //   • When you just want speed (replaceText is faster)

    await element(by.id('login_passwordInput')).replaceText(WRONG_PASSWORD);


    // ── KEYBOARD DISMISSAL ──────────────────────────────────────────────

    // After typing, the software keyboard is still open. On most phones,

    // it covers the submit button, toggles, and anything in the lower

    // half of the screen. You MUST dismiss it before tapping those elements.

    //

    // The approach differs by platform:

    if (device.getPlatform() === 'ios') {

      // iOS: Tap a non-input element to make the input lose focus.

      // The page title is a safe target — it's not a text input, so

      // tapping it just dismisses the keyboard.

      await element(by.id('login_pageTitle')).tap();

    } else {

      // Android: The back button dismisses the keyboard WITHOUT

      // navigating back (when a keyboard is currently open).

      // This is built-in Android behavior.

      await device.pressBack();

    }


    await element(by.id('login_submitButton')).tap();


    // ── waitFor with long timeout ───────────────────────────────────────

    // The app makes an API call to the backend. We wait for the error

    // message element to appear. 15 seconds covers slow servers and

    // retry logic.

    await waitFor(element(by.id('login_errorMessage')))

      .toBeVisible()

      .withTimeout(15000);

  });

```


### Scenario 5: Toggle Interaction — Feature Flags


```typescript

  it('should still show errors with Remember Me toggled ON', async () => {

    await element(by.id('welcome_loginButton')).tap();


    await element(by.id('login_emailInput')).clearText();

    await element(by.id('login_emailInput')).typeText(VALID_EMAIL);

    await element(by.id('login_passwordInput')).clearText();

    await element(by.id('login_passwordInput')).replaceText(WRONG_PASSWORD);


    // Dismiss keyboard (same cross-platform pattern as before)

    if (device.getPlatform() === 'ios') {

      await element(by.id('login_pageTitle')).tap();

    } else {

      await device.pressBack();

    }


    // ── Toggle interaction ──────────────────────────────────────────────

    // Tapping a toggle/switch changes internal Redux state.

    // We test this because a bug could cause toggling "Remember Me"

    // to accidentally clear the error message or skip validation.

    // If you don't test toggle + error combinations, these subtle

    // bugs will reach production.

    await element(by.id('login_rememberMeToggle')).tap();


    await element(by.id('login_submitButton')).tap();


    await waitFor(element(by.id('login_errorMessage')))

      .toBeVisible()

      .withTimeout(15000);

  });

```


### Scenario 6: Successful Login


```typescript

  it('should log in successfully with valid credentials', async () => {

    await element(by.id('welcome_loginButton')).tap();


    await element(by.id('login_emailInput')).clearText();

    await element(by.id('login_emailInput')).typeText(VALID_EMAIL);

    await element(by.id('login_passwordInput')).clearText();

    await element(by.id('login_passwordInput')).replaceText(VALID_PASSWORD);


    if (device.getPlatform() === 'ios') {

      await element(by.id('login_pageTitle')).tap();

    } else {

      await device.pressBack();

    }


    await element(by.id('login_submitButton')).tap();


    // ── Verify we landed on the Dashboard ───────────────────────────────

    // After successful login, the app should navigate to the Dashboard.

    // We wait for the Home tab to appear in the bottom tab bar.

    await waitFor(element(by.id('tabBar_HOME')))

      .toBeVisible()

      .withTimeout(10000);

  });

});  // end describe('Login Flow')

```


---


## Writing an Advanced Test: Multi-Screen Checkout with Payments


Now let's tackle the hard stuff — a multi-screen flow with payment gateway integration. This is where most E2E tutorials stop, but where real apps live.


```typescript

import { by, device, element, expect, waitFor } from 'detox';


describe('Checkout Flow', () => {


  // ═══════════════════════════════════════════════════════════════════════════

  // SETUP

  // ═══════════════════════════════════════════════════════════════════════════

  beforeAll(async () => {

    await device.launchApp({

      newInstance: true,

      delete: true,

      permissions: { notifications: 'YES' },

    });


    // ── URL BLACKLIST ─────────────────────────────────────────────────────

    // This might be the most important line in the entire test suite.

    //

    // Detox's synchronization engine tracks ALL in-flight network requests.

    // If a request to braintreegateway.com is polling or keeping a WebSocket

    // alive, Detox thinks the app is "busy" and REFUSES to execute the next

    // test action. Eventually it times out — a false negative.

    //

    // The blacklist tells Detox: "Ignore these URLs when deciding if the

    // app is idle." You almost certainly need this for any payment SDK.

    //

    // ⚠️ ESCAPING: Use \\. for literal dots. In JavaScript:

    //   '.*paypal\\.com.*'  → regex: .*paypal\.com.*  (correct: matches paypal.com)

    //   '.*paypal.com.*'    → regex: .*paypal.com.*   (WRONG: . matches any char)

    await device.setURLBlacklist([

      '.*paypal\\.com.*',              // PayPal keeps long-lived connections

      '.*braintreegateway\\.com.*',    // Braintree polls for payment tokens

      '.*your-image-cdn\\.com.*',      // Image CDNs load in the background

    ]);

  });


  // ═══════════════════════════════════════════════════════════════════════════

  // HELPER: Ensure we're logged in and on the right tab

  // ═══════════════════════════════════════════════════════════════════════════

  //

  // This helper doesn't actually log in — it assumes a previous test suite

  // (like the Login suite) already authenticated. It just waits for the

  // dashboard to appear and navigates to the correct tab.

  //

  // This works because:

  //   1. The Login suite ran first and authenticated

  //   2. The auth token is stored in AsyncStorage/EncryptedStorage

  //   3. On reloadReactNative(), the app checks for tokens and auto-navigates

  const ensureOnCartTab = async () => {

    await waitFor(element(by.id('tabBar_HOME')))

      .toBeVisible()

      .withTimeout(10000);


    // ── toExist() vs toBeVisible() ──────────────────────────────────────

    // toExist()    → element is in the component tree (may be off-screen)

    // toBeVisible() → element is visible on screen (within viewport)

    //

    // We use toExist() here because the CART tab might not be "visible"

    // per Detox's pixel-level check (e.g., animation in progress), but

    // it IS in the tree and tappable.

    await waitFor(element(by.id('tabBar_CART')))

      .toExist()

      .withTimeout(5000);


    await element(by.id('tabBar_CART')).tap();

  };


  // ═══════════════════════════════════════════════════════════════════════════

  // HELPER: Navigate all the way to the Payment screen

  // ═══════════════════════════════════════════════════════════════════════════

  //

  // Multiple tests need to reach the Payment screen. Instead of duplicating

  // the 4-screen navigation in each test, I extract it into a reusable helper.

  // One change in the flow = one update here, not four scattered updates.

  const navigateToPaymentScreen = async () => {


    // Reset JS state before each navigation.

    await device.reloadReactNative();

    await ensureOnCartTab();


    // Screen 1: Cart Overview

    await waitFor(element(by.id('cart_pageTitle'))).toBeVisible();

    await element(by.id('cart_checkoutButton')).tap();


    // Screen 2: Shipping Address

    await waitFor(element(by.id('shipping_pageTitle'))).toBeVisible();

    await element(by.id('shipping_savedAddress')).tap();


    // Screen 3: Delivery Method

    await waitFor(element(by.id('delivery_pageTitle'))).toBeVisible();

    await element(by.id('delivery_standardOption')).tap();


    // Screen 4: Order Summary → Payment

    await waitFor(element(by.id('summary_pageTitle')))

      .toBeVisible()

      .withTimeout(5000);

    await element(by.id('summary_totalAmount')).toBeVisible();


    // ── scroll() ────────────────────────────────────────────────────────

    // Scrolls the ScrollView by a specified distance in a direction.

    //

    // scroll(pixels, direction, startPositionX, startPositionY)

    //   pixels: how far to scroll

    //   direction: 'down', 'up', 'left', 'right'

    //   startPositionX: where on the x-axis to start (NaN = center)

    //   startPositionY: where on the y-axis to start (0.85 = 85% from top)

    //

    // The 0.85 means the swipe starts at 85% of the scroll view's height.

    // This avoids hitting elements at the very top or bottom edge.

    await element(by.id('summary_scrollView')).scroll(250, 'down', NaN, 0.85);


    // ── disableSynchronization() ────────────────────────────────────────

    // Temporarily disables Detox's idle-wait mechanism.

    //

    // WHY: The Payment screen initializes the Braintree SDK, which fires

    // background network requests (client token fetch, config, etc.).

    // Some of these are polling/persistent connections that NEVER complete.

    // With sync enabled, Detox would wait forever.

    //

    // RULE: Disable sync before interacting with:

    //   • 3rd-party native SDKs (Braintree, Stripe)

    //   • System UI sheets (Apple Pay, Google Pay)

    //   • WebViews (PayPal redirect)

    //   • Long-polling network connections

    await device.disableSynchronization();


    await element(by.id('summary_payButton')).tap();


    // Now we need to manually wait since auto-sync is off

    await waitFor(element(by.id('payment_pageTitle')))

      .toBeVisible()

      .withTimeout(15000);


    // ── enableSynchronization() ─────────────────────────────────────────

    // Re-enable Detox's idle tracking. Always do this as soon as possible

    // to get intelligent waiting back.

    await device.enableSynchronization();


    // Brief pause to let the payment SDK fully initialize

    // (fetch client token, render payment methods)

    await new Promise(resolve => setTimeout(resolve, 1000));

  };


  // ═══════════════════════════════════════════════════════════════════════════

  // SCENARIO 1: Cart Screen Integrity

  // ═══════════════════════════════════════════════════════════════════════════

  it('should display all cart elements', async () => {

    await ensureOnCartTab();


    await waitFor(element(by.id('cart_pageTitle'))).toBeVisible();

    await expect(element(by.id('cart_itemList'))).toBeVisible();

    await expect(element(by.id('cart_checkoutButton'))).toBeVisible();


    // ── toExist() for off-screen elements ───────────────────────────────

    // The promo code input is below the fold — it exists in the tree

    // but isn't visible without scrolling. Use toExist() here.

    await expect(element(by.id('cart_promoCodeInput'))).toExist();


    // ── try/catch for conditional UI ────────────────────────────────────

    // The saved payment section shows EITHER a card (if the user has one)

    // or a link (if they don't). Both states are valid depending on

    // account data we can't control in a real-backend test.

    try {

      await expect(element(by.id('cart_savedPaymentCard'))).toExist();

    } catch {

      await expect(element(by.id('cart_addPaymentLink'))).toExist();

    }

  });


  // ═══════════════════════════════════════════════════════════════════════════

  // SCENARIO 2: Apple Pay / Google Pay

  // ═══════════════════════════════════════════════════════════════════════════

  it('should process Apple Pay or Google Pay', async () => {


    // ── Platform-aware test logic ───────────────────────────────────────

    // device.getPlatform() returns 'ios' or 'android'.

    // A single test handles both platforms by computing IDs dynamically.

    const isIOS = device.getPlatform() === 'ios';

    const methodName = isIOS ? 'Apple Pay' : 'Google Pay';

    const methodTestID = isIOS ? 'applepay' : 'googlepay';

    const payButtonID = isIOS ? 'payment_applePayBtn' : 'payment_googlePayBtn';


    await navigateToPaymentScreen();


    // Select the payment method radio button

    // ── atIndex(0) ──────────────────────────────────────────────────────

    // When multiple elements match the same testID (e.g., if both a

    // RadioButtonGroup and a direct button have similar IDs), atIndex(0)

    // selects the first match. Use this when you know the order.

    await waitFor(element(by.id(`payment_method_${methodTestID}`)).atIndex(0))

      .toExist()

      .withTimeout(8000);


    // Scroll to see the payment method if it's below the fold

    try {

      await element(by.id('payment_scrollView')).scroll(200, 'down', NaN, 0.85);

    } catch (e) {

      // scroll() can throw if we're already at the bottom — that's fine

    }


    await element(by.id(`payment_method_${methodTestID}`)).atIndex(0).tap();

    await new Promise(resolve => setTimeout(resolve, 500));


    // ── scrollTo('bottom') ──────────────────────────────────────────────

    // Scrolls directly to the absolute bottom of the scroll view.

    // More reliable than scroll(9999, 'down') for reaching the pay button.

    await waitFor(element(by.id(payButtonID))).toExist().withTimeout(10000);

    await element(by.id('payment_scrollView')).scrollTo('bottom');

    await expect(element(by.id(payButtonID))).toBeVisible();


    // Apple Pay / Google Pay open SYSTEM-LEVEL payment sheets.

    // Detox has ZERO visibility into these sheets.

    // We MUST disable sync, or Detox hangs forever waiting for the

    // native sheet to become "idle" (which it never does).

    await device.disableSynchronization();

    await element(by.id(payButtonID)).tap();


    // ── THE SUCCESS / ERROR BRANCHING PATTERN ───────────────────────────

    // When testing against a REAL payment backend (not mocked), the

    // transaction outcome is NON-DETERMINISTIC. It might succeed or fail

    // depending on:

    //   • Network conditions

    //   • Payment sandbox rate limiting

    //   • Gateway maintenance windows

    //   • Test card validity

    //

    // So we consider BOTH outcomes valid. What's invalid is NEITHER

    // appearing — that means the app is stuck or crashed.

    let isSuccess = false;

    let isError = false;


    try {

      await waitFor(element(by.id('success_pageTitle')))

        .toBeVisible()

        .withTimeout(15000);

      isSuccess = true;

    } catch (e) {

      // Not success — check for error screen

    }


    if (!isSuccess) {

      try {

        await waitFor(element(by.id('error_pageTitle')))

          .toBeVisible()

          .withTimeout(5000);

        isError = true;

      } catch (e) {

        // Neither screen appeared — this is a real failure

        await device.enableSynchronization();

        throw new Error(

          `Neither Success nor Error screen appeared after ${methodName}.`

        );

      }

    }


    await device.enableSynchronization();


    // Verify we can navigate back from either outcome

    if (isSuccess) {

      await expect(element(by.id('success_homeButton'))).toBeVisible();

      await element(by.id('success_homeButton')).tap();

    } else if (isError) {

      await expect(element(by.id('error_retryButton'))).toBeVisible();

      await element(by.id('error_retryButton')).tap();

    }


    // Confirm we're back on the main screen

    await waitFor(element(by.id('tabBar_HOME')))

      .toBeVisible()

      .withTimeout(10000);

  });


  // ═══════════════════════════════════════════════════════════════════════════

  // SCENARIO 3: PayPal

  // ═══════════════════════════════════════════════════════════════════════════

  it('should process PayPal payment', async () => {

    await navigateToPaymentScreen();


    // Scroll to find PayPal option

    try {

      await element(by.id('payment_scrollView')).scroll(300, 'down', NaN, 0.85);

    } catch (e) {}


    await waitFor(element(by.id('payment_method_paypal')))

      .toExist()

      .withTimeout(20000);


    await element(by.id('payment_method_paypal')).atIndex(0).tap();

    await new Promise(resolve => setTimeout(resolve, 500));


    await waitFor(element(by.id('payment_paypalButton')))

      .toExist()

      .withTimeout(10000);


    await element(by.id('payment_scrollView')).scrollTo('bottom');

    await expect(element(by.id('payment_paypalButton'))).toBeVisible();


    // PayPal opens Safari (iOS) or Chrome Custom Tab (Android) for auth.

    // These are OUT-OF-PROCESS windows that Detox cannot interact with.

    // Disable sync so Detox doesn't hang.

    await device.disableSynchronization();

    await element(by.id('payment_paypalButton')).tap();


    // 30-second timeout: in a manual test, the tester would authorize

    // in the PayPal window. In CI, you'd use a mock PayPal environment.

    let isSuccess = false;

    let isError = false;


    try {

      await waitFor(element(by.id('success_pageTitle')))

        .toBeVisible()

        .withTimeout(30000);

      isSuccess = true;

    } catch (e) {}


    if (!isSuccess) {

      try {

        await waitFor(element(by.id('error_pageTitle')))

          .toBeVisible()

          .withTimeout(5000);

        isError = true;

      } catch (e) {

        await device.enableSynchronization();

        throw new Error('Neither Success nor Error after PayPal.');

      }

    }


    await device.enableSynchronization();


    if (isSuccess) {

      await element(by.id('success_homeButton')).tap();

    } else if (isError) {

      await element(by.id('error_retryButton')).tap();

    }


    await waitFor(element(by.id('tabBar_HOME')))

      .toBeVisible()

      .withTimeout(10000);

  });


  // ═══════════════════════════════════════════════════════════════════════════

  // SCENARIO 4: Credit Card (via Braintree native form)

  // ═══════════════════════════════════════════════════════════════════════════

  it('should process credit card payment', async () => {

    await navigateToPaymentScreen();


    // Scroll to credit card option

    try {

      await element(by.id('payment_scrollView')).scroll(500, 'down', NaN, 0.85);

    } catch (e) {}


    await waitFor(element(by.id('payment_method_creditcard')).atIndex(0))

      .toExist()

      .withTimeout(8000);


    await element(by.id('payment_method_creditcard')).atIndex(0).tap();

    await new Promise(resolve => setTimeout(resolve, 1000)); // Wait for form to expand


    // ── by.type() — TARGETING NATIVE SDK ELEMENTS ───────────────────────

    //

    // This is the hardest part of payment testing. Credit card fields are

    // rendered by the BRAINTREE NATIVE SDK, not by React Native. They're

    // actual UITextField (iOS) / EditText (Android) elements with NO

    // testID at all — because they come from the native SDK, not our

    // React components.

    //

    // Solution: target them by their native class type and position.

    //

    // by.type('UITextField')              → matches iOS native text fields

    // by.type('android.widget.EditText')  → matches Android native text fields

    //

    // .atIndex(N) selects the Nth match (0-indexed):

    //   Index 0 = Cardholder Name

    //   Index 1 = Card Number

    //   Index 2 = Expiry

    //   Index 3 = CVV


    const isIOS = device.getPlatform() === 'ios';

    const fieldType = isIOS ? 'UITextField' : 'android.widget.EditText';


    // Field 0: Cardholder Name

    await element(by.type(fieldType)).atIndex(0).tap();

    await element(by.type(fieldType)).atIndex(0).typeText('John Doe');


    // Scroll 100px after each field to bring the NEXT field into view.

    // typeText() requires the element to be visible, so this incremental

    // scrolling is essential.

    await element(by.id('payment_scrollView')).scroll(100, 'down', NaN, 0.85);


    // Field 1: Card Number (Visa test card)

    await element(by.type(fieldType)).atIndex(1).tap();

    await element(by.type(fieldType)).atIndex(1).typeText('4111111111111111');


    await element(by.id('payment_scrollView')).scroll(100, 'down', NaN, 0.85);


    // Field 2: Expiry (MMYY format)

    await element(by.type(fieldType)).atIndex(2).tap();

    await element(by.type(fieldType)).atIndex(2).typeText('1228');


    await element(by.id('payment_scrollView')).scroll(100, 'down', NaN, 0.85);


    // Field 3: CVV

    await element(by.type(fieldType)).atIndex(3).tap();

    await element(by.type(fieldType)).atIndex(3).typeText('123');


    await element(by.id('payment_scrollView')).scroll(100, 'down', NaN, 0.85);


    // Submit the form

    await element(by.id('payment_scrollView')).scrollTo('bottom');

    await element(by.id('payment_submitButton')).tap();


    // Same success/error branching pattern

    let isSuccess = false;

    let isError = false;


    try {

      await waitFor(element(by.id('success_pageTitle')))

        .toBeVisible()

        .withTimeout(30000);    // 30s for card processing + possible 3D Secure

      isSuccess = true;

    } catch (e) {}


    if (!isSuccess) {

      try {

        await waitFor(element(by.id('error_pageTitle')))

          .toBeVisible()

          .withTimeout(5000);

        isError = true;

      } catch (e) {

        throw new Error('Neither Success nor Error after Credit Card.');

      }

    }


    if (isSuccess) {

      await element(by.id('success_homeButton')).tap();

    } else if (isError) {

      await element(by.id('error_retryButton')).tap();

    }


    await waitFor(element(by.id('tabBar_HOME')))

      .toBeVisible()

      .withTimeout(15000);

  });

});  // end describe('Checkout Flow')

```


---


## Every Detox API Used in This Blog — Quick Reference


Here's a cheat sheet of every Detox method we used, with what it does:


### Device APIs


| Method | What It Does |

|---|---|

| `device.launchApp({ newInstance, delete, permissions })` | Launches the app with options. `newInstance` kills any running instance. `delete` wipes app data. `permissions` pre-grants system dialogs. |

| `device.reloadReactNative()` | Reloads only the JS bundle (~3x faster than relaunch). Resets React state, preserves native state. |

| `device.getPlatform()` | Returns `'ios'` or `'android'`. Use for platform-specific logic. |

| `device.pressBack()` | Android: presses the hardware back button. Dismisses keyboard when one is open. |

| `device.disableSynchronization()` | Pauses Detox's idle tracking. Use before 3rd-party SDKs, payment sheets, or WebViews. |

| `device.enableSynchronization()` | Re-enables idle tracking. Always call this ASAP after the problematic section. |

| `device.setURLBlacklist([...])` | Tells Detox to ignore specific URLs when deciding if the app is idle. Essential for payment SDKs. |


### Element Matchers (`by`)


| Matcher | What It Matches |

|---|---|

| `by.id('testID')` | Element with matching `testID` prop. **Primary selector.** |

| `by.text('Hello')` | Element displaying exact text. Fragile — breaks on copy changes. |

| `by.type('UITextField')` | Native element by class name. Use for 3rd-party SDK elements without testIDs. |


### Element Actions


| Action | What It Does |

|---|---|

| `element(...).tap()` | Simulates a tap at the element's center. |

| `element(...).typeText('value')` | Types characters one by one. Opens the keyboard. |

| `element(...).replaceText('value')` | Sets text atomically. No per-character callbacks. Use for password fields. |

| `element(...).clearText()` | Clears all text. Always call before typeText/replaceText. |

| `element(...).scroll(px, dir, x, y)` | Scrolls `px` pixels in `dir` direction. `x`/`y` are start position ratios. |

| `element(...).scrollTo('bottom')` | Scrolls to the absolute bottom of a ScrollView. |

| `element(...).atIndex(n)` | When multiple elements match, selects the nth one (0-indexed). |


### Assertions


| Assertion | What It Checks |

|---|---|

| `expect(el).toBeVisible()` | Element is visible within the current viewport (on-screen). |

| `expect(el).toExist()` | Element exists in the component tree (may be off-screen). |

| `expect(el).toHaveText('value')` | Element displays the exact text. |

| `expect(el).not.toBeVisible()` | Element is NOT visible. |


### Waiters


| Waiter | What It Does |

|---|---|

| `waitFor(el).toBeVisible().withTimeout(ms)` | Polls every ~100ms. Passes when element becomes visible. Fails after timeout. |

| `waitFor(el).toExist().withTimeout(ms)` | Same, but checks existence in tree (not visibility). |


---


## Running Your Tests


### Build First (One-Time or After Code Changes)


```bash

# iOS

npm run detox:build:ios


# Android

npm run detox:build:android

```


### Then Run


```bash

# iOS — full suite

npm run detox:test:ios


# Android — full suite

npm run detox:test:android


# Run a specific test file

npx detox test --configuration ios.sim.debug path/to/your.test.ts


# Run a specific scenario by its name

npx detox test --configuration ios.sim.debug -t "should log in successfully"

```


---


## Troubleshooting: The 5 Failures You'll Definitely Hit


### 1. "Element not found" on Android but passes on iOS


The element probably isn't rendered yet. Android sometimes has slightly different rendering timing.


**Fix**: Always use `waitFor` before interacting:

```typescript

await waitFor(element(by.id('my_element')))

  .toExist()

  .withTimeout(5000);    // Give it time to render

await element(by.id('my_element')).tap();

```


### 2. Test hangs forever and eventually times out


Detox's synchronization is waiting for a network request that never completes.


**Fix**: Add the URL to the blacklist:

```typescript

await device.setURLBlacklist(['.*problematic-api\\.com.*']);

```


### 3. Can't tap the submit button — keyboard is in the way


The on-screen keyboard is covering the element you want to tap.


**Fix**: Dismiss the keyboard first:

```typescript

if (device.getPlatform() === 'ios') {

  await element(by.id('any_non_input_element')).tap();

} else {

  await device.pressBack();

}

```


### 4. `by.type('UITextField')` matches the wrong field


Multiple native text fields exist in the view hierarchy.


**Fix**: Use `.atIndex()` and verify the order by checking the native view hierarchy. On iOS:

```bash

xcrun simctl io booted screenshot /tmp/debug.png

```


### 5. Android test APK not found


Your Gradle build variant doesn't match the binary path in `.detoxrc.js`.


**Fix**: Double-check your paths. If you use product flavors, include them:

```bash

# With "Development" flavor:

./gradlew assembleDevelopmentDebug assembleDevelopmentDebugAndroidTest


# NOT just:

./gradlew assembleDebug assembleDebugAndroidTest

```


---


## Choosing the Right Timeout — A Cheat Sheet


| Situation | Timeout | Why |

|---|---|---|

| Screen navigation animation | 5,000ms | Animations take < 500ms; 5s buffers slow devices |

| API login response | 15,000ms | Server round-trip + token generation + possible retries |

| Payment gateway processing | 30,000ms | External gateway + possible 3D Secure verification |

| Post-payment redirect back to app | 15,000ms | App state reconciliation after payment callback |

| PayPal / external browser flow | 30,000–60,000ms | Manual intervention or sandbox latency |


---


## Key Takeaways


Building this test suite taught me a few things that no doc or tutorial prepared me for:


1. **`testID` everything from day one.** Retrofitting testIDs into an existing app is painful. If you start early, it's free. Every button, every input, every link — give it an ID. Future-you will be grateful.


2. **Master the synchronisation toggle.** Understanding when to disable and re-enable Detox's idle tracking is the difference between a flaky suite and a reliable one. Payment SDKs, WebViews, and system sheets all need manual sync management.


3. **Blacklist third-party URLs aggressively.** Payment SDKs maintain persistent connections that will hang your entire test suite. Identify them early and blacklist them.


4. **Use platform-aware patterns everywhere.** `device.getPlatform()` isn't just for test IDs — use it for keyboard dismissal, native element types, and payment method selection.


5. **Embrace non-determinism in real-backend tests.** When you test against real payment gateways, transactions can succeed or fail for reasons outside your control. Test for *behaviour* (did the app show a result screen?), not for specific *outcomes* (did it succeed?).


6. **Co-locate tests with screens.** Putting `login.test.ts` inside `src/screens/auth/login/` makes it obvious which screens a test covers. It also makes code review simpler.


7. **Extract navigation helpers.** If 5 scenarios all navigate through the same 4 screens, extract it into a function. One change in the flow means one update in the helper, not five scattered updates.


8. **`clearText()` before every `typeText()`.** Always. Even when the field "should" be empty. Defensive coding isn't paranoia in E2E testing — it's survival.


---


*Thanks for reading! If this helped you set up Detox in your React Native project, I'd love to hear about it. Drop me a message or share your own E2E testing war stories. Happy testing! 🚀*


Post a Comment

0 Comments