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! 🚀*
0 Comments