by thetestingacademy
Gray box E2E testing for React Native apps on iOS and Android simulators
npx @qaskills/cli add detox-mobileAuto-detects your AI agent and installs the skill. Works with Claude Code, Cursor, Copilot, and more.
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.
sleep() in a Detox suite is a bug.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.assembleRelease / -configuration Release binaries.device.launchApp({ newInstance: true }) or device.reloadReactNative() in beforeEach; tests that depend on the previous test's screen are unmaintainable.device.launchApp({ permissions: { notifications: 'YES', location: 'inuse' } }) sets iOS permissions deterministically; tapping system dialogs is flaky and Detox cannot see them anyway.device.disableSynchronization() to the smallest possible window.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
/** @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' },
},
};
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
// 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();
});
});
// 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();
});
// 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();
});
# .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
testID props during feature development, not retroactively; treat a missing testID as a review comment.e2e/jest.config.js separate from the unit-test Jest config (maxWorkers: 1, longer timeouts, Detox environment).--record-videos failing --take-screenshots failing in CI so every red test ships with visual evidence.detoxEnableMockServer flag) rather than tapping through logout flows in every test.--headless) and pin the AVD image version; emulator image drift is a top source of "works locally" failures.device.disableSynchronization() plus waitFor(...).withTimeout(...), then device.enableSynchronization() in a finally block.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.by.text() for anything that will be localized or copy-edited.--cleanup, leaving zombie simulators that exhaust CI runner disk and memory.detox in devDependencies, a .detoxrc.js, or an e2e/ folder with Detox tests.- name: Install QA Skills
run: npx @qaskills/cli add detox-mobile10 of 29 agents supported
Go from zero to Playwright pro: Page Object Model, fixtures, and CI/CD on real projects.
Use code PROMODE at checkout