Detox Mobile Testing
This skill makes an AI agent write and run Detox gray-box E2E tests for React Native apps: configure .detoxrc.js for iOS simulators and Android emulators, build test binaries, write tests with element(by.id(...)) matchers, control the app lifecycle with device.launchApp, and lean on Detox's automatic synchronization instead of sleeps. Trigger it in React Native repositories containing an e2e/ directory, detox in package.json, or when the user asks for end-to-end tests on iOS/Android simulators.
Core Principles
- Detox is gray-box: it waits for the app to be idle. Detox monitors the JS event loop, network requests, timers, and animations, and only acts when the app is quiescent. Trust this; almost every
sleep() in a Detox suite is a bug.
- Match by
testID, never by text or traversal. Text changes with copy edits and localization; view hierarchy changes with refactors. Add testID="login-button" props in the app code as part of writing the test.
- Test release builds. Dev builds bundle the dev menu, yellow boxes, and a Metro dependency that makes timing unrealistic. CI must run
assembleRelease / -configuration Release binaries.
- Each test starts from a known app state. Use
device.launchApp({ newInstance: true }) or device.reloadReactNative() in beforeEach; tests that depend on the previous test's screen are unmaintainable.
- Handle permissions at launch, not with dialog-clicking.
device.launchApp({ permissions: { notifications: 'YES', location: 'inuse' } }) sets iOS permissions deterministically; tapping system dialogs is flaky and Detox cannot see them anyway.
- Disable synchronization only as a last resort, and re-enable immediately. Endless animations (spinners, maps, video) can keep the app permanently busy; scope
device.disableSynchronization() to the smallest possible window.
Setup
npm install --save-dev detox jest @types/jest
# iOS dependency for simulator control
brew tap wix/brew
brew install applesimutils
# Scaffold e2e/ folder and config
npx detox init
.detoxrc.js
// .detoxrc.js
/** @type {Detox.DetoxConfig} */
module.exports = {
testRunner: {
args: {
config: 'e2e/jest.config.js',
_: ['e2e'],
},
jest: { setupTimeout: 120000 },
},
apps: {
'ios.release': {
type: 'ios.app',
binaryPath: 'ios/build/Build/Products/Release-iphonesimulator/ShopApp.app',
build:
'xcodebuild -workspace ios/ShopApp.xcworkspace -scheme ShopApp -configuration Release -sdk iphonesimulator -derivedDataPath ios/build',
},
'android.release': {
type: 'android.apk',
binaryPath: 'android/app/build/outputs/apk/release/app-release.apk',
build:
'cd android && ./gradlew assembleRelease assembleAndroidTest -DtestBuildType=release && cd ..',
},
},
devices: {
simulator: { type: 'ios.simulator', device: { type: 'iPhone 15' } },
emulator: { type: 'android.emulator', device: { avdName: 'Pixel_7_API_34' } },
},
configurations: {
'ios.sim.release': { device: 'simulator', app: 'ios.release' },
'android.emu.release': { device: 'emulator', app: 'android.release' },
},
};
Build, then test
npx detox build --configuration ios.sim.release
npx detox test --configuration ios.sim.release --cleanup
npx detox build --configuration android.emu.release
npx detox test --configuration android.emu.release --headless --record-logs failing
Patterns
1. Login flow with matchers and lifecycle control
// e2e/login.test.js
describe('Login', () => {
beforeAll(async () => {
await device.launchApp({
newInstance: true,
permissions: { notifications: 'YES' },
});
});
beforeEach(async () => {
await device.reloadReactNative();
});
it('logs in with valid credentials', async () => {
await element(by.id('email-input')).typeText('qa@example.com');
await element(by.id('password-input')).typeText('Str0ngPass!');
await element(by.id('login-button')).tap();
await expect(element(by.id('home-screen'))).toBeVisible();
await expect(element(by.text('Welcome back'))).toBeVisible();
});
it('shows a validation error for a bad password', async () => {
await element(by.id('email-input')).typeText('qa@example.com');
await element(by.id('password-input')).typeText('nope');
await element(by.id('login-button')).tap();
await expect(element(by.id('login-error'))).toHaveText('Invalid email or password');
await expect(element(by.id('home-screen'))).not.toBeVisible();
});
});
2. Explicit waits and scrolling for late content
// e2e/orders.test.js
it('renders orders fetched from the API', async () => {
await element(by.id('tab-orders')).tap();
// Wait for async content beyond the automatic idle sync
await waitFor(element(by.id('orders-list')))
.toBeVisible()
.withTimeout(10000);
// Scroll inside the list until a row appears
await waitFor(element(by.text('Order #1042')))
.toBeVisible()
.whileElement(by.id('orders-list'))
.scroll(250, 'down');
await element(by.text('Order #1042')).tap();
await expect(element(by.id('order-detail-screen'))).toBeVisible();
});
3. Deep links, backgrounding, and multi-instance launches
// e2e/deeplink.test.js
it('opens a product from a deep link', async () => {
await device.launchApp({
newInstance: true,
url: 'shopapp://products/SKU-1042',
});
await expect(element(by.id('product-screen'))).toBeVisible();
await expect(element(by.id('product-sku'))).toHaveText('SKU-1042');
});
it('survives backgrounding mid-checkout', async () => {
await element(by.id('checkout-button')).tap();
await device.sendToHome();
await device.launchApp({ newInstance: false });
await expect(element(by.id('checkout-screen'))).toBeVisible();
});
4. GitHub Actions: iOS simulator on macOS runners
# .github/workflows/detox-ios.yml
name: detox-ios
on: [pull_request]
jobs:
ios-e2e:
runs-on: macos-14
timeout-minutes: 45
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- run: cd ios && pod install && cd ..
- name: Install simulator utils
run: brew tap wix/brew && brew install applesimutils
- name: Build app for Detox
run: npx detox build --configuration ios.sim.release
- name: Run Detox tests
run: npx detox test --configuration ios.sim.release --cleanup --record-videos failing --take-screenshots failing
- name: Upload failure artifacts
if: failure()
uses: actions/upload-artifact@v4
with:
name: detox-artifacts
path: artifacts
Best Practices
- Add
testID props during feature development, not retroactively; treat a missing testID as a review comment.
- Keep
e2e/jest.config.js separate from the unit-test Jest config (maxWorkers: 1, longer timeouts, Detox environment).
- Use
--record-videos failing --take-screenshots failing in CI so every red test ships with visual evidence.
- Reset app state through launch arguments your app understands (for example a
detoxEnableMockServer flag) rather than tapping through logout flows in every test.
- Run Android tests headless in CI (
--headless) and pin the AVD image version; emulator image drift is a top source of "works locally" failures.
- Quarantine the rare animation-heavy screen with
device.disableSynchronization() plus waitFor(...).withTimeout(...), then device.enableSynchronization() in a finally block.
Anti-Patterns
await new Promise(r => setTimeout(r, 5000)) between steps: Detox's synchronization already waits for idle; sleeps only slow the suite and mask real sync bugs.
- Matching by
by.text() for anything that will be localized or copy-edited.
- Testing against a debug build connected to Metro in CI, then wondering why timing differs from production.
- One mega-test that logs in, browses, checks out, and edits the profile; when step 14 fails you re-run 13 steps to debug it.
- Asserting on internal state via custom native modules instead of what is visible on screen.
- Skipping
--cleanup, leaving zombie simulators that exhaust CI runner disk and memory.
When to Trigger This Skill
- A React Native repository contains
detox in devDependencies, a .detoxrc.js, or an e2e/ folder with Detox tests.
- The user asks for E2E tests of a React Native app on the iOS simulator or Android emulator.
- Flaky mobile tests full of sleeps need migration to synchronized Detox waits.
- A mobile CI pipeline (GitHub Actions macOS runner, Android emulator job) needs to build and run device tests.
- Prefer Detox for React Native projects; recommend Appium or Maestro instead for native-only apps or teams that want black-box, framework-agnostic flows.
1---2name: detox-mobile-testing3description: Gray-box end-to-end testing for React Native apps with Detox. Covers .detoxrc.js configuration, build and test commands, matchers, device.launchApp control, automatic synchronization, and macOS CI pipelines.4license: MIT5---67# Detox Mobile Testing89This skill makes an AI agent write and run Detox gray-box E2E tests for React Native apps: configure `.detoxrc.js` for iOS simulators and Android emulators, build test binaries, write tests with `element(by.id(...))` matchers, control the app lifecycle with `device.launchApp`, and lean on Detox's automatic synchronization instead of sleeps. Trigger it in React Native repositories containing an `e2e/` directory, `detox` in package.json, or when the user asks for end-to-end tests on iOS/Android simulators.1011## Core Principles12131. **Detox is gray-box: it waits for the app to be idle.** Detox monitors the JS event loop, network requests, timers, and animations, and only acts when the app is quiescent. Trust this; almost every `sleep()` in a Detox suite is a bug.142. **Match by `testID`, never by text or traversal.** Text changes with copy edits and localization; view hierarchy changes with refactors. Add `testID="login-button"` props in the app code as part of writing the test.153. **Test release builds.** Dev builds bundle the dev menu, yellow boxes, and a Metro dependency that makes timing unrealistic. CI must run `assembleRelease` / `-configuration Release` binaries.164. **Each test starts from a known app state.** Use `device.launchApp({ newInstance: true })` or `device.reloadReactNative()` in `beforeEach`; tests that depend on the previous test's screen are unmaintainable.175. **Handle permissions at launch, not with dialog-clicking.** `device.launchApp({ permissions: { notifications: 'YES', location: 'inuse' } })` sets iOS permissions deterministically; tapping system dialogs is flaky and Detox cannot see them anyway.186. **Disable synchronization only as a last resort, and re-enable immediately.** Endless animations (spinners, maps, video) can keep the app permanently busy; scope `device.disableSynchronization()` to the smallest possible window.1920## Setup2122```bash23npm install --save-dev detox jest @types/jest24# iOS dependency for simulator control25brew tap wix/brew26brew install applesimutils27# Scaffold e2e/ folder and config28npx detox init29```3031### .detoxrc.js3233```js34// .detoxrc.js35/** @type {Detox.DetoxConfig} */36module.exports = {37 testRunner: {38 args: {39 config: 'e2e/jest.config.js',40 _: ['e2e'],41 },42 jest: { setupTimeout: 120000 },43 },44 apps: {45 'ios.release': {46 type: 'ios.app',47 binaryPath: 'ios/build/Build/Products/Release-iphonesimulator/ShopApp.app',48 build:49 'xcodebuild -workspace ios/ShopApp.xcworkspace -scheme ShopApp -configuration Release -sdk iphonesimulator -derivedDataPath ios/build',50 },51 'android.release': {52 type: 'android.apk',53 binaryPath: 'android/app/build/outputs/apk/release/app-release.apk',54 build:55 'cd android && ./gradlew assembleRelease assembleAndroidTest -DtestBuildType=release && cd ..',56 },57 },58 devices: {59 simulator: { type: 'ios.simulator', device: { type: 'iPhone 15' } },60 emulator: { type: 'android.emulator', device: { avdName: 'Pixel_7_API_34' } },61 },62 configurations: {63 'ios.sim.release': { device: 'simulator', app: 'ios.release' },64 'android.emu.release': { device: 'emulator', app: 'android.release' },65 },66};67```6869### Build, then test7071```bash72npx detox build --configuration ios.sim.release73npx detox test --configuration ios.sim.release --cleanup7475npx detox build --configuration android.emu.release76npx detox test --configuration android.emu.release --headless --record-logs failing77```7879## Patterns8081### 1. Login flow with matchers and lifecycle control8283```js84// e2e/login.test.js85describe('Login', () => {86 beforeAll(async () => {87 await device.launchApp({88 newInstance: true,89 permissions: { notifications: 'YES' },90 });91 });9293 beforeEach(async () => {94 await device.reloadReactNative();95 });9697 it('logs in with valid credentials', async () => {98 await element(by.id('email-input')).typeText('qa@example.com');99 await element(by.id('password-input')).typeText('Str0ngPass!');100 await element(by.id('login-button')).tap();101102 await expect(element(by.id('home-screen'))).toBeVisible();103 await expect(element(by.text('Welcome back'))).toBeVisible();104 });105106 it('shows a validation error for a bad password', async () => {107 await element(by.id('email-input')).typeText('qa@example.com');108 await element(by.id('password-input')).typeText('nope');109 await element(by.id('login-button')).tap();110111 await expect(element(by.id('login-error'))).toHaveText('Invalid email or password');112 await expect(element(by.id('home-screen'))).not.toBeVisible();113 });114});115```116117### 2. Explicit waits and scrolling for late content118119```js120// e2e/orders.test.js121it('renders orders fetched from the API', async () => {122 await element(by.id('tab-orders')).tap();123124 // Wait for async content beyond the automatic idle sync125 await waitFor(element(by.id('orders-list')))126 .toBeVisible()127 .withTimeout(10000);128129 // Scroll inside the list until a row appears130 await waitFor(element(by.text('Order #1042')))131 .toBeVisible()132 .whileElement(by.id('orders-list'))133 .scroll(250, 'down');134135 await element(by.text('Order #1042')).tap();136 await expect(element(by.id('order-detail-screen'))).toBeVisible();137});138```139140### 3. Deep links, backgrounding, and multi-instance launches141142```js143// e2e/deeplink.test.js144it('opens a product from a deep link', async () => {145 await device.launchApp({146 newInstance: true,147 url: 'shopapp://products/SKU-1042',148 });149 await expect(element(by.id('product-screen'))).toBeVisible();150 await expect(element(by.id('product-sku'))).toHaveText('SKU-1042');151});152153it('survives backgrounding mid-checkout', async () => {154 await element(by.id('checkout-button')).tap();155 await device.sendToHome();156 await device.launchApp({ newInstance: false });157 await expect(element(by.id('checkout-screen'))).toBeVisible();158});159```160161### 4. GitHub Actions: iOS simulator on macOS runners162163```yaml164# .github/workflows/detox-ios.yml165name: detox-ios166on: [pull_request]167168jobs:169 ios-e2e:170 runs-on: macos-14171 timeout-minutes: 45172 steps:173 - uses: actions/checkout@v4174 - uses: actions/setup-node@v4175 with:176 node-version: 20177 cache: npm178 - run: npm ci179 - run: cd ios && pod install && cd ..180 - name: Install simulator utils181 run: brew tap wix/brew && brew install applesimutils182 - name: Build app for Detox183 run: npx detox build --configuration ios.sim.release184 - name: Run Detox tests185 run: npx detox test --configuration ios.sim.release --cleanup --record-videos failing --take-screenshots failing186 - name: Upload failure artifacts187 if: failure()188 uses: actions/upload-artifact@v4189 with:190 name: detox-artifacts191 path: artifacts192```193194## Best Practices195196- Add `testID` props during feature development, not retroactively; treat a missing `testID` as a review comment.197- Keep `e2e/jest.config.js` separate from the unit-test Jest config (`maxWorkers: 1`, longer timeouts, Detox environment).198- Use `--record-videos failing --take-screenshots failing` in CI so every red test ships with visual evidence.199- Reset app state through launch arguments your app understands (for example a `detoxEnableMockServer` flag) rather than tapping through logout flows in every test.200- Run Android tests headless in CI (`--headless`) and pin the AVD image version; emulator image drift is a top source of "works locally" failures.201- Quarantine the rare animation-heavy screen with `device.disableSynchronization()` plus `waitFor(...).withTimeout(...)`, then `device.enableSynchronization()` in a `finally` block.202203## Anti-Patterns204205- `await new Promise(r => setTimeout(r, 5000))` between steps: Detox's synchronization already waits for idle; sleeps only slow the suite and mask real sync bugs.206- Matching by `by.text()` for anything that will be localized or copy-edited.207- Testing against a debug build connected to Metro in CI, then wondering why timing differs from production.208- One mega-test that logs in, browses, checks out, and edits the profile; when step 14 fails you re-run 13 steps to debug it.209- Asserting on internal state via custom native modules instead of what is visible on screen.210- Skipping `--cleanup`, leaving zombie simulators that exhaust CI runner disk and memory.211212## When to Trigger This Skill213214- A React Native repository contains `detox` in devDependencies, a `.detoxrc.js`, or an `e2e/` folder with Detox tests.215- The user asks for E2E tests of a React Native app on the iOS simulator or Android emulator.216- Flaky mobile tests full of sleeps need migration to synchronized Detox waits.217- A mobile CI pipeline (GitHub Actions macOS runner, Android emulator job) needs to build and run device tests.218- Prefer Detox for React Native projects; recommend Appium or Maestro instead for native-only apps or teams that want black-box, framework-agnostic flows.