mobile-testing-detox
Detox E2E gray-box testing for React Native - matchers, actions, expectations, waitFor, device API, synchronization, mocking, artifacts, CI integration
Install
npx skills add https://github.com/agents-inc/skills/tree/main/dist/plugins/mobile-testing-detox/skills/mobile-testing-detox
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install agents-inc-skills@llmmart
git clone https://github.com/agents-inc/skills.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole agents-inc/skills collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
Detox E2E Testing Patterns
Quick Guide: Detox is a gray-box E2E testing framework for React Native. It synchronizes with the app's JS thread, native UI, and network automatically -- eliminating flaky
sleep()calls. Match elements withby.id()(preferred), act with.tap()/.typeText(), assert withexpect().toBeVisible(). UsewaitFor().withTimeout()only when automatic sync fails. Always addtestIDto interactive elements and forward it to native components. Mocking happens via Metro source extensions, not Jest mocks.
<critical_requirements>
CRITICAL: Before Using This Skill
All code must follow project conventions in CLAUDE.md (kebab-case, named exports, import ordering,
import type, named constants)
(You MUST add testID props to every interactive element and forward them to native components -- Detox cannot find custom components without forwarded testID)
(You MUST use by.id() as the primary matcher -- it is locale-agnostic, stable across UI changes, and decoupled from display text)
(You MUST call waitFor().withTimeout() only as a last resort -- Detox auto-synchronizes with JS, UI, and network by default)
(You MUST use Metro source extensions (.mock.js / .e2e.js) for mocking -- Jest mocks do not work in Detox E2E tests)
(You MUST set a withTimeout() on every waitFor call -- calling waitFor without a timeout does nothing)
</critical_requirements>
Auto-detection: Detox, detox, .detoxrc.js, detox.config.js, by.id, by.text, by.label, element(), expect(), waitFor, device.launchApp, device.reloadReactNative, device.terminateApp, device.disableSynchronization, testID, E2E test React Native, gray-box testing, detox test, detox build
When to use:
- Writing end-to-end tests for React Native apps on iOS and Android
- Configuring Detox device, app, and artifact settings in
.detoxrc.js - Matching elements, performing actions, and asserting expectations
- Handling synchronization issues with animations or long-polling
- Mocking network responses or app configuration for E2E tests
- Setting up CI pipelines for automated Detox test runs
- Debugging flaky tests caused by synchronization problems
Key patterns covered:
.detoxrc.jsconfiguration (devices, apps, configurations, artifacts)- Element matchers (
by.id,by.text,by.label, compound matchers) - Actions (
tap,typeText,scroll,swipe,longPress) - Expectations (
toBeVisible,toExist,toHaveText,not) waitForwith polling andwithTimeoutfor manual synchronization- Device API (
launchApp,reloadReactNative,terminateApp, biometrics) - Mocking via Metro bundler source extensions
- Artifacts (screenshots, videos, logs) and CI integration
- testID strategy and naming conventions
When NOT to use:
- Unit or component testing (use your project's unit test runner)
- Web-only React applications (Detox is mobile-only)
- Apps built with Flutter, Swift, or Kotlin (Detox is React Native focused)
- Simple snapshot or render tests (use component testing tools)
Detailed Resources:
- examples/core.md - Matchers, actions, expectations, waitFor, testID strategy
- examples/synchronization.md - Animation handling, manual sync, debug synchronization
- examples/ci-artifacts.md - Artifacts configuration, CI workflows, mocking with Metro
- reference.md - Decision frameworks, matcher/action/expectation tables, checklists
<decision_framework>
Decision Framework
Which Matcher to Use
Need to find an element?
|
+-> Has a testID?
| +-> YES -> by.id("testID") (always preferred)
| +-> NO -> Can you add one?
| +-> YES -> Add testID, use by.id()
| +-> NO -> Continue...
|
+-> Has unique visible text?
| +-> YES -> by.text("exact text")
| +-> NO -> by.label("accessibility label")
|
+-> Multiple matches?
+-> Use .atIndex(n) or compound matchers:
by.id("x").withAncestor(by.id("parent"))
When to Use waitFor vs Fix Synchronization
Test is flaky / element not found?
|
+-> Is there a looping animation?
| +-> YES -> Mock the animation in tests (Metro extension)
| or disable via launch arg
|
+-> Is there a long-polling / WebSocket connection?
| +-> YES -> device.setURLBlacklist([".*long-poll.*"])
|
+-> Is there a setTimeout loop?
| +-> YES -> Convert to setInterval (Detox ignores setInterval)
|
+-> None of the above?
+-> Use waitFor().toBeVisible().withTimeout(ms)
+-> Enable --debug-synchronization to find the blocker
Test Lifecycle Strategy
How to reset between tests?
|
+-> Need clean JS state only?
| +-> device.reloadReactNative() (fast, reloads JS bundle)
|
+-> Need clean app data + permissions?
| +-> device.launchApp({ delete: true }) (reinstalls app)
|
+-> Need clean device state?
+-> device.resetContentAndSettings() (full simulator reset, iOS)
</decision_framework>
<red_flags>
RED FLAGS
High Priority Issues:
- Using
sleep()or manual delays instead of Detox auto-synchronization -- Detox waits for idle automatically; sleep masks real issues - Calling
waitFor()without.withTimeout()-- does nothing, silently passes without waiting - Using
by.text()as the primary matcher -- breaks on locale changes and text updates; useby.id() - Adding
testIDto a custom component without forwarding to a native component -- Detox cannot find it - Using Jest mocks (
jest.mock()) in Detox tests -- E2E tests run in the app process, not Jest; use Metro source extensions
Medium Priority Issues:
- Using
device.launchApp({ delete: true })in everybeforeEach-- extremely slow; usereloadReactNative()unless you need clean storage - Not using
--record-videos failingand--take-screenshots failingin CI -- makes debugging failed CI tests impossible - Hardcoded timeout values -- use named constants (
LOGIN_TIMEOUT_MS, not5000) - Using
by.type()for matching -- platform-specific class names differ between iOS and Android
Gotchas & Edge Cases:
toBeVisible()checks 75% screen visibility by default -- an element cantoExist()but nottoBeVisible()if it is offscreen or obscuredtypeText()requires the element to be focused first on some platforms -- tap the input before typing iftypeTextfailsby.traits()is iOS only -- no Android equivalent existsreloadReactNative()does not clear AsyncStorage, MMKV, or other persistent storage -- uselaunchApp({ delete: true })for that- Looping animations (spinners, pulse effects) block Detox synchronization indefinitely -- mock them or use
disableSynchronization()+waitFor setURLBlacklistaccepts an array of regex strings, not plain URLs -- escape dots and slashes properly- Android emulator tests need
reversePorts: [8081]in the app config or Metro bundler is unreachable device.disableSynchronization()is global -- always re-enable withdevice.enableSynchronization()in anafterEachorfinallyblockgetAttributes()returns different shapes on iOS vs Android -- check platform before accessing specific fields- FlashList/FlatList items may not have
testIDaccessible until scrolled into view -- usewaitFor().whileElement().scroll()pattern
</red_flags>
<critical_reminders>
CRITICAL REMINDERS
All code must follow project conventions in CLAUDE.md
(You MUST add testID props to every interactive element and forward them to native components -- Detox cannot find custom components without forwarded testID)
(You MUST use by.id() as the primary matcher -- it is locale-agnostic, stable across UI changes, and decoupled from display text)
(You MUST call waitFor().withTimeout() only as a last resort -- Detox auto-synchronizes with JS, UI, and network by default)
(You MUST use Metro source extensions (.mock.js / .e2e.js) for mocking -- Jest mocks do not work in Detox E2E tests)
(You MUST set a withTimeout() on every waitFor call -- calling waitFor without a timeout does nothing)
Failure to follow these rules will produce flaky tests, unmatchable elements, and silent test passes that verify nothing.
</critical_reminders>
Files (skills)
-
examples
-
ci-artifacts.md 10.9 KB
# CI & Artifacts Patterns > Artifacts configuration, CI workflows, and mocking with Metro. See also: [core.md](core.md), [synchronization.md](synchronization.md). --- ## Complete .detoxrc.js Configuration ```javascript // .detoxrc.js /** @type {import('detox').DetoxConfig} */ module.exports = { testRunner: { args: { $0: "jest", config: "e2e/jest.config.js", }, jest: { setupTimeout: 120000, }, }, apps: { "ios.debug": { type: "ios.app", binaryPath: "ios/build/Build/Products/Debug-iphonesimulator/MyApp.app", build: "xcodebuild -workspace ios/MyApp.xcworkspace -scheme MyApp -configuration Debug -sdk iphonesimulator -derivedDataPath ios/build", }, "android.debug": { type: "android.apk", binaryPath: "android/app/build/outputs/apk/debug/app-debug.apk", build: "cd android && ./gradlew assembleDebug assembleAndroidTest -DtestBuildType=debug", reversePorts: [8081], }, }, devices: { simulator: { type: "ios.simulator", device: { type: "iPhone 16" } }, emulator: { type: "android.emulator", device: { avdName: "Pixel_7_API_34" }, }, }, configurations: { "ios.sim.debug": { device: "simulator", app: "ios.debug" }, "android.emu.debug": { device: "emulator", app: "android.debug" }, }, }; ``` **Key decisions:** - `setupTimeout: 120000` -- 2 minutes for CI builds (default 60s often insufficient) - `reversePorts: [8081]` -- Android emulators need port forwarding to reach Metro bundler - Use JSDoc `@type` for IDE autocomplete on all config options --- ## Pattern 1: Artifacts Configuration ### .detoxrc.js Artifacts Section ```javascript /** @type {import('detox').DetoxConfig} */ module.exports = { // ... devices, apps, configurations ... artifacts: { rootDir: "./e2e/artifacts", plugins: { screenshot: { enabled: true, shouldTakeAutomaticSnapshots: true, keepOnlyFailedTestsArtifacts: true, takeWhen: { testStart: false, testDone: true, appNotReady: true, }, }, video: { enabled: true, keepOnlyFailedTestsArtifacts: true, android: { bitRate: 4000000 }, simulator: { codec: "hevc" }, }, log: { enabled: true, keepOnlyFailedTestsArtifacts: true, }, }, }, }; ``` **Why good:** keeps only failing test artifacts (saves disk space in CI), screenshots on test completion and app crashes, HEVC codec for smaller video files. ### CLI Artifact Flags (Override Config) ```bash # Record everything for debugging detox test --configuration ios.sim.debug \ --record-videos all \ --take-screenshots all \ --record-logs all # Record only on failure (CI default) detox test --configuration ios.sim.debug \ --record-videos failing \ --take-screenshots failing \ --record-logs failing \ --artifacts-location ./e2e/artifacts/ ``` ### Per-Test Screenshots ```typescript it("should display profile after login", async () => { await element(by.id("login-btn")).tap(); // Manual screenshot at specific point await device.takeScreenshot("after-login"); await expect(element(by.id("profile-screen"))).toBeVisible(); // Element-level screenshot await element(by.id("profile-card")).takeScreenshot("profile-card"); }); ``` --- ## Pattern 2: Mocking with Metro Source Extensions ### Basic Module Override ```javascript // src/services/api-client.js -- production const API_BASE_URL = "https://api.production.com"; export function createApiClient() { return { baseURL: API_BASE_URL }; } // src/services/api-client.mock.js -- test override const API_BASE_URL = "http://localhost:9090"; export function createApiClient() { return { baseURL: API_BASE_URL }; } ``` ### Environment-Based Metro Configuration ```javascript // metro.config.js const { getDefaultConfig, mergeConfig } = require("@react-native/metro-config"); const defaultSourceExts = require("metro-config/src/defaults/defaults").sourceExts; const config = { resolver: { sourceExts: process.env.DETOX_MODE === "mocked" ? ["mock.js", "mock.ts", "mock.tsx", ...defaultSourceExts] : defaultSourceExts, }, }; module.exports = mergeConfig(getDefaultConfig(__dirname), config); ``` Start with mocking enabled: ```bash DETOX_MODE=mocked npx react-native start ``` ### Package.json Scripts ```json { "scripts": { "e2e:build:ios": "detox build --configuration ios.sim.debug", "e2e:test:ios": "DETOX_MODE=mocked detox test --configuration ios.sim.debug", "e2e:build:android": "detox build --configuration android.emu.debug", "e2e:test:android": "DETOX_MODE=mocked detox test --configuration android.emu.debug" } } ``` ### Mock Server Pattern ```javascript // src/services/api.mock.js // Point API to a local mock server running in CI const MOCK_SERVER_PORT = 9090; export const API_URL = `http://localhost:${MOCK_SERVER_PORT}`; export async function fetchProducts() { const response = await fetch(`${API_URL}/products`); return response.json(); } ``` **Why good:** production code unchanged, test infrastructure entirely separate, Metro resolves `.mock.js` files automatically when source extensions are configured. --- ## Pattern 3: Jest Integration ### e2e/jest.config.js ```javascript /** @type {import('@jest/types').Config.InitialOptions} */ module.exports = { rootDir: "..", testMatch: ["<rootDir>/e2e/**/*.test.ts"], testTimeout: 120000, maxWorkers: 1, // Detox tests must run sequentially globalSetup: "detox/runners/jest/globalSetup", globalTeardown: "detox/runners/jest/globalTeardown", reporters: ["detox/runners/jest/reporter"], testEnvironment: "detox/runners/jest/testEnvironment", verbose: true, }; ``` **Why good:** sequential execution (required for device), Detox-provided setup/teardown handles lifecycle, long timeout for slow CI machines. ### Test File Structure ```typescript // e2e/checkout.test.ts import { by, device, element, expect, waitFor } from "detox"; const CHECKOUT_TIMEOUT_MS = 10000; describe("Checkout flow", () => { beforeAll(async () => { await device.launchApp({ newInstance: true }); }); beforeEach(async () => { await device.reloadReactNative(); // Navigate to product screen await element(by.id("nav-products-tab")).tap(); }); it("should add item to cart and complete purchase", async () => { await element(by.id("product-list.item-0")).tap(); await element(by.id("product-detail.add-to-cart-btn")).tap(); await element(by.id("nav-cart-tab")).tap(); await expect(element(by.id("cart-screen.item-count"))).toHaveText("1 item"); await element(by.id("cart-screen.checkout-btn")).tap(); await waitFor(element(by.id("order-confirmation"))) .toBeVisible() .withTimeout(CHECKOUT_TIMEOUT_MS); }); }); ``` --- ## Pattern 4: CI Workflow (GitHub Actions) ### iOS Workflow ```yaml name: Detox iOS on: [push] jobs: detox-ios: runs-on: macos-14 # Apple Silicon runner timeout-minutes: 60 steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 20 cache: npm - name: Install dependencies run: npm ci - name: Install macOS dependencies run: | brew tap wix/brew brew install applesimutils - name: Install CocoaPods run: cd ios && pod install - name: Build for Detox run: npx detox build --configuration ios.sim.debug - name: Run Detox tests run: | npx detox test --configuration ios.sim.debug \ --headless \ --record-videos failing \ --take-screenshots failing \ --record-logs failing - name: Upload artifacts on failure if: failure() uses: actions/upload-artifact@v4 with: name: detox-ios-artifacts path: e2e/artifacts/ retention-days: 7 ``` ### Android Workflow ```yaml name: Detox Android on: [push] jobs: detox-android: runs-on: ubuntu-latest timeout-minutes: 60 steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 20 cache: npm - name: Install dependencies run: npm ci - uses: actions/setup-java@v4 with: distribution: temurin java-version: 17 cache: gradle - name: Build for Detox run: npx detox build --configuration android.emu.debug - name: Run Detox tests uses: reactivecircus/android-emulator-runner@v2 with: api-level: 34 arch: x86_64 profile: Pixel 7 emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim script: | npx detox test --configuration android.emu.debug \ --headless \ --record-videos failing \ --take-screenshots failing \ --record-logs failing - name: Upload artifacts on failure if: failure() uses: actions/upload-artifact@v4 with: name: detox-android-artifacts path: e2e/artifacts/ retention-days: 7 ``` **Why good:** Apple Silicon runner for iOS (faster builds), `--headless` for CI, artifacts uploaded only on failure, proper caching for dependencies. --- ## Pattern 5: Multi-App Testing Test interactions between multiple apps (e.g., app + notification extension). ```javascript // .detoxrc.js module.exports = { apps: { "ios.main": { type: "ios.app", binaryPath: "ios/build/Build/Products/Debug-iphonesimulator/MainApp.app", build: "xcodebuild -workspace ios/MainApp.xcworkspace ...", }, "ios.companion": { type: "ios.app", binaryPath: "ios/build/Build/Products/Debug-iphonesimulator/CompanionApp.app", build: "xcodebuild -workspace ios/CompanionApp.xcworkspace ...", }, }, configurations: { "ios.multi": { device: "simulator", apps: ["ios.main", "ios.companion"], }, }, }; ``` ```typescript // Switch between apps during test it("should sync data between apps", async () => { await device.selectApp("ios.main"); await device.launchApp({ newInstance: true }); await element(by.id("create-item-btn")).tap(); await device.selectApp("ios.companion"); await device.launchApp({ newInstance: true }); await expect(element(by.id("synced-item"))).toBeVisible(); }); ``` --- ## Anti-Pattern: Not Collecting CI Artifacts ```bash # Bad: no artifacts, impossible to debug failures detox test --configuration ios.sim.debug ``` **Why bad:** when a test fails in CI, you have no screenshots, videos, or logs to diagnose the issue. Debugging becomes guesswork. ```bash # Good: collect artifacts on failure detox test --configuration ios.sim.debug \ --record-videos failing \ --take-screenshots failing \ --record-logs failing ``` **Why good:** failing tests produce videos and screenshots that show exactly what the UI looked like at failure time. -
core.md 12.7 KB
# Core Patterns > Matchers, actions, expectations, waitFor, device API, and testID strategy. See also: [synchronization.md](synchronization.md), [ci-artifacts.md](ci-artifacts.md). --- ## Pattern 1: Element Matchers ### by.id -- Preferred Matcher ```typescript // Match by testID (always preferred) await element(by.id("login-btn")).tap(); await element(by.id("email-input")).typeText("user@example.com"); // Regex matching await element(by.id(/^product-item-\d+$/)).tap(); ``` ### by.text and by.label -- Fallbacks ```typescript // Match by visible text (fragile -- locale-dependent) await element(by.text("Sign In")).tap(); // Match by accessibility label await element(by.label("Close")).tap(); // Case-insensitive regex await element(by.text(/welcome/i)).tap(); ``` ### Compound Matchers ```typescript // AND: must match both await element(by.id("item").and(by.text("Widget"))).tap(); // Ancestor: find child within specific parent await element(by.id("price").withAncestor(by.id("product-card"))).tap(); // Descendant: find parent containing specific child await element(by.id("card").withDescendant(by.id("sale-badge"))).tap(); // Index: when multiple elements match, select by position await element(by.text("Add")).atIndex(0).tap(); ``` ### Good vs Bad Matcher Usage ```typescript // Good: stable, locale-agnostic await element(by.id("checkout-btn")).tap(); ``` **Why good:** testID does not change with locale, text updates, or styling changes. Survives refactors. ```typescript // Bad: breaks on locale change or text update await element(by.text("Proceed to Checkout")).tap(); ``` **Why bad:** if the button text changes to "Go to Checkout" or the app adds Spanish support, the test breaks. --- ## Pattern 2: Actions ### Tap and Press ```typescript // Simple tap await element(by.id("submit-btn")).tap(); // Tap at specific coordinates within element await element(by.id("map-view")).tap({ x: 150, y: 200 }); // Double tap await element(by.id("image")).multiTap(2); const LONG_PRESS_MS = 1500; // Long press with duration await element(by.id("item")).longPress({ x: 50, y: 50 }, LONG_PRESS_MS); ``` ### Text Input ```typescript // Type with keyboard simulation (triggers onChangeText) await element(by.id("email-input")).tap(); // Focus first if needed await element(by.id("email-input")).typeText("test@example.com"); // Replace text directly (faster, no keyboard events) await element(by.id("search-input")).replaceText("new search term"); // Clear text await element(by.id("name-input")).clearText(); // Keyboard actions await element(by.id("password-input")).tapReturnKey(); await element(by.id("text-field")).tapBackspaceKey(); ``` ### Scroll and Swipe ```typescript const SCROLL_DISTANCE = 300; const SMALL_SCROLL = 100; // Scroll down by pixels await element(by.id("scroll-view")).scroll(SCROLL_DISTANCE, "down"); // Scroll to edge await element(by.id("scroll-view")).scrollTo("bottom"); // Scroll until element found (with waitFor) await waitFor(element(by.id("footer-item"))) .toBeVisible() .whileElement(by.id("scroll-view")) .scroll(SMALL_SCROLL, "down"); // Swipe gestures await element(by.id("carousel")).swipe("left", "fast"); await element(by.id("dismissible-card")).swipe("right", "slow", 0.75); ``` ### Date Picker and Slider ```typescript // Set date picker value await element(by.id("date-picker")).setDatePickerDate("2025-06-15", "ISO8601"); // Adjust slider to 75% const SLIDER_POSITION = 0.75; await element(by.id("volume-slider")).adjustSliderToPosition(SLIDER_POSITION); ``` ### Getting Element Attributes ```typescript // Read element properties const attrs = await element(by.id("counter-text")).getAttributes(); // attrs.text -> current text value // attrs.enabled -> whether element is enabled // attrs.visible -> whether element is visible ``` --- ## Pattern 3: Expectations ### Visibility and Existence ```typescript // Element is visible on screen (default: 75% visible) await expect(element(by.id("welcome-banner"))).toBeVisible(); // Custom visibility threshold (at least 50% visible) const HALF_VISIBLE = 50; await expect(element(by.id("partial-view"))).toBeVisible(HALF_VISIBLE); // Element exists in hierarchy but may be offscreen await expect(element(by.id("hidden-data"))).toExist(); // Element does NOT exist await expect(element(by.id("deleted-row"))).not.toExist(); // Element is not visible (hidden or offscreen) await expect(element(by.id("loading-spinner"))).not.toBeVisible(); ``` ### Text and Value Assertions ```typescript // Exact text match await expect(element(by.id("greeting"))).toHaveText("Hello, World"); // Accessibility label await expect(element(by.id("icon"))).toHaveLabel("Settings"); // Accessibility value await expect(element(by.id("progress-bar"))).toHaveValue("75%"); // Toggle/switch state await expect(element(by.id("dark-mode-toggle"))).toHaveToggleValue(true); // Slider position const EXPECTED_VOLUME = 0.5; const SLIDER_TOLERANCE = 0.05; await expect(element(by.id("volume"))).toHaveSliderPosition( EXPECTED_VOLUME, SLIDER_TOLERANCE, ); // Focus state await expect(element(by.id("search-input"))).toBeFocused(); ``` ### Good vs Bad Expectation Usage ```typescript // Good: specific assertion after specific action await element(by.id("save-btn")).tap(); await expect(element(by.id("success-toast"))).toBeVisible(); await expect(element(by.id("form"))).not.toBeVisible(); ``` **Why good:** verifies the expected result of the action (toast appears, form hides). ```typescript // Bad: vague assertion, no connection to user action await expect(element(by.id("screen"))).toExist(); ``` **Why bad:** `toExist()` passes even if the element is hidden behind another view. Use `toBeVisible()` to verify what the user sees. --- ## Pattern 4: waitFor with Polling ### Basic waitFor ```typescript const LOAD_TIMEOUT_MS = 10000; // Wait for element to appear after async operation await waitFor(element(by.id("dashboard-header"))) .toBeVisible() .withTimeout(LOAD_TIMEOUT_MS); // Wait for element to disappear await waitFor(element(by.id("loading-overlay"))) .not.toBeVisible() .withTimeout(LOAD_TIMEOUT_MS); ``` ### waitFor with Scroll ```typescript const SCROLL_STEP = 100; // Scroll list until element is found await waitFor(element(by.id("item-99"))) .toBeVisible() .whileElement(by.id("product-list")) .scroll(SCROLL_STEP, "down"); ``` ### Good vs Bad waitFor Usage ```typescript // Good: waitFor only when auto-sync cannot help const ANIMATION_TIMEOUT_MS = 3000; await waitFor(element(by.id("animated-result"))) .toBeVisible() .withTimeout(ANIMATION_TIMEOUT_MS); ``` **Why good:** named timeout constant, used because a custom animation blocks auto-sync. ```typescript // Bad: using waitFor everywhere "just in case" await waitFor(element(by.id("static-label"))) .toBeVisible() .withTimeout(5000); ``` **Why bad:** static elements should be visible immediately with auto-sync. Adding waitFor masks real issues and slows tests. --- ## Pattern 5: Device API ### App Lifecycle in Tests ```typescript describe("Login flow", () => { beforeAll(async () => { await device.launchApp({ newInstance: true }); }); beforeEach(async () => { await device.reloadReactNative(); }); afterAll(async () => { await device.terminateApp(); }); it("should log in with valid credentials", async () => { await element(by.id("email-input")).typeText("user@example.com"); await element(by.id("password-input")).typeText("password123"); await element(by.id("login-btn")).tap(); await expect(element(by.id("home-screen"))).toBeVisible(); }); }); ``` ### Deep Links and Notifications ```typescript // Launch with deep link await device.launchApp({ newInstance: true, url: "myapp://product/42", }); await expect(element(by.id("product-detail"))).toBeVisible(); // Open URL while app is running await device.openURL({ url: "myapp://settings" }); await expect(element(by.id("settings-screen"))).toBeVisible(); ``` ### Permissions and Biometrics (iOS) ```typescript // Launch with pre-granted permissions await device.launchApp({ newInstance: true, permissions: { notifications: "YES", camera: "YES", location: "inuse", }, }); // Biometric authentication await device.setBiometricEnrollment(true); await element(by.id("biometric-login-btn")).tap(); await device.matchFace(); // Simulate successful Face ID await expect(element(by.id("home-screen"))).toBeVisible(); // Failed biometric await element(by.id("biometric-login-btn")).tap(); await device.unmatchFace(); await expect(element(by.id("biometric-error"))).toBeVisible(); ``` ### Location Mocking ```typescript const NYC_LAT = 40.7128; const NYC_LON = -74.006; await device.setLocation(NYC_LAT, NYC_LON); await element(by.id("find-nearby-btn")).tap(); await expect(element(by.id("nyc-results"))).toBeVisible(); ``` --- ## Pattern 6: testID Strategy ### Naming Convention Use a consistent, hierarchical naming pattern: ```tsx // Screen-level prefix with dot-separated hierarchy <View testID="login-screen"> <TextInput testID="login-screen.email-input" /> <TextInput testID="login-screen.password-input" /> <Pressable testID="login-screen.submit-btn"> <Text>Log In</Text> </Pressable> <Pressable testID="login-screen.forgot-password-link"> <Text>Forgot Password?</Text> </Pressable> </View> ``` ### Custom Component Forwarding ```tsx // MUST forward testID to a native component interface ListItemProps { testID?: string; title: string; subtitle: string; onPress: () => void; } function ListItem({ testID, title, subtitle, onPress }: ListItemProps) { return ( <Pressable testID={testID} onPress={onPress}> <Text testID={testID ? `${testID}.title` : undefined}>{title}</Text> <Text testID={testID ? `${testID}.subtitle` : undefined}>{subtitle}</Text> </Pressable> ); } ``` ### List Items with Unique IDs ```tsx // Generate unique testIDs for list items function ProductList({ products }: { products: Product[] }) { const renderItem = useCallback( ({ item, index }: { item: Product; index: number }) => ( <ListItem testID={`product-list.item-${index}`} title={item.name} subtitle={item.price} onPress={() => handlePress(item.id)} /> ), [handlePress], ); return ( <FlatList testID="product-list" data={products} renderItem={renderItem} keyExtractor={(item) => item.id} /> ); } // In tests: await element(by.id("product-list.item-0.title")).tap(); await element(by.id("product-list.item-2")).swipe("left"); ``` ### Good vs Bad testID Patterns ```tsx // Good: stable, descriptive, hierarchical <Pressable testID="cart-screen.checkout-btn" onPress={checkout}> <Text>Proceed to Checkout</Text> </Pressable> ``` **Why good:** stable name, describes screen context and element role, survives text changes. ```tsx // Bad: based on display text, will break on text change <Pressable testID="proceed-to-checkout" onPress={checkout}> <Text>Proceed to Checkout</Text> </Pressable> ``` **Why bad:** if button text changes to "Go to Payment", the testID becomes misleading and you will likely update it too, breaking tests. --- ## Pattern 7: Complete Test Example ```typescript const LOGIN_TIMEOUT_MS = 5000; describe("Authentication", () => { beforeAll(async () => { await device.launchApp({ newInstance: true }); }); beforeEach(async () => { await device.reloadReactNative(); }); describe("Login", () => { it("should show error for invalid credentials", async () => { await element(by.id("login-screen.email-input")).typeText( "bad@email.com", ); await element(by.id("login-screen.password-input")).typeText("wrong"); await element(by.id("login-screen.submit-btn")).tap(); await waitFor(element(by.id("login-screen.error-message"))) .toBeVisible() .withTimeout(LOGIN_TIMEOUT_MS); await expect(element(by.id("login-screen.error-message"))).toHaveText( "Invalid credentials", ); }); it("should navigate to home on valid login", async () => { await element(by.id("login-screen.email-input")).typeText( "user@example.com", ); await element(by.id("login-screen.password-input")).typeText( "valid-password", ); await element(by.id("login-screen.submit-btn")).tap(); await waitFor(element(by.id("home-screen"))) .toBeVisible() .withTimeout(LOGIN_TIMEOUT_MS); await expect(element(by.id("home-screen.welcome-text"))).toBeVisible(); }); }); describe("Logout", () => { it("should return to login screen", async () => { // Assume already logged in via beforeEach or helper await element(by.id("settings-tab")).tap(); await element(by.id("settings-screen.logout-btn")).tap(); await expect(element(by.id("login-screen"))).toBeVisible(); }); }); }); ``` -
synchronization.md 7.2 KB
# Synchronization Patterns > Handling animations, manual synchronization, and debugging sync issues. See also: [core.md](core.md), [ci-artifacts.md](ci-artifacts.md). --- ## How Detox Auto-Synchronization Works Detox automatically waits for: - **JS thread** to be idle (no pending promises, timers, or microtasks) - **Native UI** animations to complete - **Network requests** to finish (unless blacklisted) - **React Native bridge** to be idle Only use manual synchronization (`waitFor`, `disableSynchronization`) when auto-sync genuinely cannot handle the situation. --- ## Pattern 1: Dealing with Looping Animations Looping animations (spinners, pulse effects, shimmer placeholders) block Detox indefinitely because the app never reaches an idle state. ### Solution A: Mock Animations via Metro (Preferred) ```javascript // src/components/loading-spinner.js -- production import { Animated, Easing } from "react-native"; export function startPulse(animValue: Animated.Value) { Animated.loop( Animated.timing(animValue, { toValue: 1, duration: 1000, easing: Easing.inOut(Easing.ease), useNativeDriver: true, }), ).start(); } // src/components/loading-spinner.mock.js -- test override export function startPulse(_animValue: Animated.Value) { // No-op: disables the animation loop for Detox tests } ``` **Why good:** production code untouched, animation disabled only in test builds, Detox auto-sync works normally. ### Solution B: Disable Synchronization Temporarily ```typescript const ANIMATION_TIMEOUT_MS = 3000; it("should show content behind a looping spinner", async () => { await device.disableSynchronization(); try { await waitFor(element(by.id("content-loaded"))) .toBeVisible() .withTimeout(ANIMATION_TIMEOUT_MS); await expect(element(by.id("data-text"))).toHaveText("Results"); } finally { await device.enableSynchronization(); } }); ``` **Why good:** `try/finally` ensures sync is always re-enabled, waitFor polls until content appears, explicit timeout prevents infinite hang. **When to use:** when you cannot mock the animation (third-party library, native animation). --- ## Pattern 2: Long-Polling and WebSocket Connections Persistent connections prevent the network idle state. Blacklist their URLs. ### URL Blacklisting ```typescript beforeAll(async () => { await device.launchApp({ newInstance: true }); // Blacklist long-polling and WebSocket endpoints await device.setURLBlacklist([ ".*\\/long-poll\\/.*", ".*\\.socket\\.io.*", ".*\\/realtime\\/.*", ]); }); ``` **Why good:** Detox stops monitoring these URLs for idle state, auto-sync works for everything else. ### Launch-Time Blacklisting ```javascript // .detoxrc.js -- blacklist during launch module.exports = { configurations: { "ios.sim.debug": { device: "simulator", app: "ios.debug", // Override at configuration level behavior: { launchApp: { detoxURLBlacklistRegex: "(.*long-poll.*|.*socket\\.io.*)", }, }, }, }, }; ``` --- ## Pattern 3: setTimeout Loops Detox tracks `setTimeout` calls but ignores `setInterval`. If your app uses `setTimeout` in a loop pattern, it blocks synchronization. ### Solution: Convert to setInterval ```javascript // Bad: setTimeout loop blocks Detox sync function pollStatus() { setTimeout(() => { checkStatus(); pollStatus(); // Recursive setTimeout }, 1000); } // Good: setInterval is ignored by Detox sync const POLL_INTERVAL_MS = 1000; const intervalId = setInterval(checkStatus, POLL_INTERVAL_MS); // Clear when done: clearInterval(intervalId); ``` **Why good:** Detox intentionally ignores `setInterval`, so the app reaches idle state between intervals. --- ## Pattern 4: Debugging Synchronization Issues When tests hang or time out, enable synchronization debugging to find what blocks the idle loop. ### CLI Debug Flag ```bash # Log sync status every 5 seconds detox test --configuration ios.sim.debug --debug-synchronization 5000 ``` Output shows what Detox is waiting for: ``` The app is busy with the following tasks: - 1 enqueued native animation - 2 pending network requests (https://api.example.com/data) - 1 pending timer (setTimeout, 30000ms remaining) ``` ### Systematic Debugging Process 1. **Enable debug sync** with `--debug-synchronization 5000` 2. **Identify the blocker** from the output (animation, network, timer) 3. **Fix the root cause:** - Animation loop -> Mock it via Metro extension - Network request -> Blacklist the URL or fix the server - Timer -> Convert setTimeout loop to setInterval 4. **Verify fix** by running the test without `waitFor` or `disableSynchronization` --- ## Pattern 5: Selective Synchronization Disable For screens with unavoidable animations (e.g., third-party map SDKs, video players), disable sync only for specific interactions. ```typescript const MAP_LOAD_TIMEOUT_MS = 5000; it("should interact with map screen", async () => { // Navigate to map (auto-sync works here) await element(by.id("nav-map-tab")).tap(); // Disable sync for map screen (map SDK uses continuous animations) await device.disableSynchronization(); try { // Must use waitFor since auto-sync is off await waitFor(element(by.id("map-view"))) .toBeVisible() .withTimeout(MAP_LOAD_TIMEOUT_MS); await element(by.id("map-pin-1")).tap(); await waitFor(element(by.id("pin-detail-card"))) .toBeVisible() .withTimeout(MAP_LOAD_TIMEOUT_MS); await expect(element(by.id("pin-detail-card.title"))).toHaveText( "Central Park", ); } finally { await device.enableSynchronization(); } }); ``` **Why good:** sync disabled only for the problematic screen, re-enabled in `finally` block, all waitFor calls have timeouts. --- ## Pattern 6: Launch Arguments for Test Mode Pass arguments at launch to disable animations app-wide in test builds. ```typescript beforeAll(async () => { await device.launchApp({ newInstance: true, launchArgs: { disableAnimations: "true", }, }); }); ``` In the app, check the launch argument: ```typescript // src/config.ts import { NativeModules } from "react-native"; const launchArgs = NativeModules.RNDetoxLaunchArgs ?? {}; export const IS_DETOX_TEST = launchArgs.disableAnimations === "true"; ``` ```typescript // src/app.tsx import { IS_DETOX_TEST } from "./config"; import { UIManager } from "react-native"; if (IS_DETOX_TEST) { UIManager.setLayoutAnimationEnabledExperimental?.(false); } ``` **Why good:** animations disabled at the source, no need for `disableSynchronization`, auto-sync works normally. --- ## Anti-Pattern: Using sleep() ```typescript // Bad: arbitrary delay, flaky, slow await element(by.id("save-btn")).tap(); await new Promise((resolve) => setTimeout(resolve, 3000)); await expect(element(by.id("success-toast"))).toBeVisible(); ``` **Why bad:** the 3-second wait is arbitrary -- it may be too short on slow CI machines or unnecessarily long on fast ones. Masks real synchronization issues. ```typescript // Good: let Detox auto-sync handle it await element(by.id("save-btn")).tap(); await expect(element(by.id("success-toast"))).toBeVisible(); ``` **Why good:** Detox waits exactly as long as needed. If auto-sync is insufficient, investigate what blocks idle rather than adding a timer.
-
-
reference.md 11.4 KB
# Detox Quick Reference > Decision frameworks, matcher/action/expectation tables, and checklists. See [SKILL.md](SKILL.md) for patterns and red flags. --- ## Matcher Reference | Matcher | Matches By | React Native Prop | Notes | | --------------------- | --------------------------------------------------------- | --------------------- | ----------------------------------------------------------------------- | | `by.id(id)` | Accessibility identifier | `testID` | **Preferred.** Supports regex. | | `by.text(text)` | Displayed text content | Text children | Supports regex. Fragile across locales. | | `by.label(label)` | Accessibility label (iOS) / content description (Android) | `accessibilityLabel` | Supports regex. | | `by.type(className)` | Native class name | N/A | Platform-specific (`RCTImageView` vs `android.widget.ImageView`). | | `by.traits([traits])` | Accessibility traits | `accessibilityTraits` | **iOS only.** Values: `"button"`, `"link"`, `"header"`, `"image"`, etc. | ### Compound Matchers | Method | Purpose | Example | | -------------------------- | -------------------------- | ------------------------------------------------ | | `.and(matcher)` | Combine matchers | `by.id("x").and(by.text("y"))` | | `.withAncestor(matcher)` | Match with parent | `by.id("child").withAncestor(by.id("parent"))` | | `.withDescendant(matcher)` | Match with child | `by.id("parent").withDescendant(by.id("child"))` | | `.atIndex(n)` | Select nth match (0-based) | `by.text("Item").atIndex(2)` | ### Regex Support `by.id()`, `by.text()`, and `by.label()` accept regex: ```typescript element(by.id(/^item-\d+$/)); element(by.text(/welcome/i)); // case-insensitive ``` --- ## Action Reference | Action | Signature | Notes | | ------------------------ | ------------------------------------------------------ | ------------------------------------------------------- | | `tap` | `.tap(point?)` | `point`: `{x, y}` optional | | `multiTap` | `.multiTap(times)` | Single gesture with N taps | | `longPress` | `.longPress(point?, duration?)` | Duration in ms | | `typeText` | `.typeText(text)` | Uses system keyboard. Element must be focused. | | `replaceText` | `.replaceText(text)` | Direct replacement, no keyboard simulation | | `clearText` | `.clearText()` | Clears all text | | `tapReturnKey` | `.tapReturnKey()` | Tap keyboard return/enter | | `tapBackspaceKey` | `.tapBackspaceKey()` | Tap keyboard backspace | | `scroll` | `.scroll(offset, direction, startX?, startY?)` | Direction: `"up"/"down"/"left"/"right"` | | `scrollTo` | `.scrollTo(edge, startX?, startY?)` | Edge: `"top"/"bottom"/"left"/"right"` | | `swipe` | `.swipe(direction, speed?, offset?, startX?, startY?)` | Speed: `"fast"/"slow"` | | `pinch` | `.pinch(scale, speed?, angle?)` | **iOS only.** Scale > 1 = zoom in | | `setDatePickerDate` | `.setDatePickerDate(dateStr, format)` | Format: `"ISO8601"` or custom | | `adjustSliderToPosition` | `.adjustSliderToPosition(pos)` | `pos`: 0.0 to 1.0 | | `getAttributes` | `.getAttributes()` | Returns element properties (text, label, enabled, etc.) | | `takeScreenshot` | `.takeScreenshot(name)` | Captures element screenshot | --- ## Expectation Reference | Expectation | Signature | Notes | | ---------------------- | ---------------------------------------- | ---------------------------------- | | `toBeVisible` | `.toBeVisible(pct?)` | Default: 75% visible. `pct`: 1-100 | | `toExist` | `.toExist()` | In hierarchy, may not be visible | | `toBeFocused` | `.toBeFocused()` | Element has input focus | | `toHaveText` | `.toHaveText(text)` | Exact text match | | `toHaveLabel` | `.toHaveLabel(label)` | Accessibility label match | | `toHaveId` | `.toHaveId(id)` | Accessibility identifier match | | `toHaveValue` | `.toHaveValue(value)` | Accessibility value match | | `toHaveSliderPosition` | `.toHaveSliderPosition(pos, tolerance?)` | `pos`: 0.0 to 1.0 | | `toHaveToggleValue` | `.toHaveToggleValue(bool)` | Switch/checkbox state | ### Modifiers | Modifier | Purpose | | ------------------ | ------------------------------------------------------ | | `.not` | Negate any expectation: `expect(el).not.toBeVisible()` | | `.withTimeout(ms)` | Wait up to `ms` before failing (on expectations) | --- ## Device API Reference ### App Lifecycle | Method | Purpose | Notes | | -------------------------------- | ------------------------------ | ------------------------------- | | `device.launchApp(params)` | Launch or relaunch app | See params below | | `device.terminateApp(bundleId?)` | Stop the app | Uses current app if no bundleId | | `device.reloadReactNative()` | Reload JS bundle | Fast; does NOT clear storage | | `device.installApp(path?)` | Install app binary | | | `device.uninstallApp(bundleId?)` | Remove app | | | `device.selectApp(name)` | Switch between configured apps | | ### launchApp Parameters | Parameter | Type | Purpose | | ------------------- | ------- | --------------------------------- | | `newInstance` | boolean | Terminate and relaunch | | `delete` | boolean | Uninstall/reinstall (clean state) | | `url` | string | Deep link launch | | `launchArgs` | object | Custom launch arguments | | `permissions` | object | Runtime permissions (iOS) | | `languageAndLocale` | object | Set language/locale (iOS) | | `resetAppState` | boolean | Clear app data before launch | ### Device Control | Method | Purpose | Platform | | ------------------------------- | ------------------------------ | -------- | | `device.sendToHome()` | Background app | Both | | `device.openURL({url})` | Open URL in app | Both | | `device.setLocation(lat, lon)` | Mock GPS | Both | | `device.setOrientation(orient)` | Portrait/landscape | Both | | `device.shake()` | Simulate shake | iOS | | `device.pressBack()` | Back button | Android | | `device.takeScreenshot(name?)` | Capture screenshot | Both | | `device.getPlatform()` | Returns `"ios"` or `"android"` | Both | ### Synchronization Control | Method | Purpose | | ----------------------------------- | --------------------------------- | | `device.disableSynchronization()` | Stop auto-sync (global) | | `device.enableSynchronization()` | Resume auto-sync | | `device.setURLBlacklist([regexes])` | Exclude URLs from sync monitoring | ### Biometrics (iOS) | Method | Purpose | | ------------------------------------- | --------------------------------- | | `device.setBiometricEnrollment(bool)` | Enable/disable Face ID / Touch ID | | `device.matchFace()` | Simulate successful Face ID | | `device.unmatchFace()` | Simulate failed Face ID | | `device.matchFinger()` | Simulate successful Touch ID | | `device.unmatchFinger()` | Simulate failed Touch ID | --- ## testID Naming Conventions | Convention | Example | Notes | | --------------------- | ---------------------------- | ----------------------- | | Screen prefix | `login-screen.email-input` | Dot-separated hierarchy | | Action suffix | `submit-btn`, `search-input` | Describes element role | | List items with index | `product-item.${index}` | Unique per item | | Child elements | `${parentTestID}.title` | Derived from parent | **Rules:** - Use kebab-case or dot-separated hierarchy consistently - Never include display text in testID names - Keep testIDs stable across refactors --- ## CLI Commands ```bash # Build app for testing detox build --configuration ios.sim.debug # Run all tests detox test --configuration ios.sim.debug # Run specific test file detox test --configuration ios.sim.debug e2e/login.test.ts # Run with artifacts on failure detox test --configuration ios.sim.debug \ --record-videos failing \ --take-screenshots failing \ --record-logs failing # Debug synchronization (logs every 5s what blocks idle) detox test --configuration ios.sim.debug --debug-synchronization 5000 # Headless mode (CI) detox test --configuration ios.sim.debug --headless ``` --- ## New Test File Checklist - [ ] Import `by`, `device`, `element`, `expect`, `waitFor` from `detox` - [ ] `beforeAll` / `beforeEach` calls `device.launchApp()` or `device.reloadReactNative()` - [ ] Every interactive element has a `testID` prop forwarded to a native component - [ ] Primary matchers use `by.id()` not `by.text()` - [ ] Timeout constants are named (e.g., `NAVIGATION_TIMEOUT_MS`) - [ ] No `sleep()` or manual delay calls - [ ] Every `waitFor` has a `.withTimeout()` - [ ] `afterAll` cleans up (terminates app if needed) -
SKILL.md 17.5 KB
--- name: mobile-testing-detox description: Detox E2E gray-box testing for React Native - matchers, actions, expectations, waitFor, device API, synchronization, mocking, artifacts, CI integration --- # Detox E2E Testing Patterns > **Quick Guide:** Detox is a gray-box E2E testing framework for React Native. It synchronizes with the app's JS thread, native UI, and network automatically -- eliminating flaky `sleep()` calls. Match elements with `by.id()` (preferred), act with `.tap()` / `.typeText()`, assert with `expect().toBeVisible()`. Use `waitFor().withTimeout()` only when automatic sync fails. Always add `testID` to interactive elements and forward it to native components. Mocking happens via Metro source extensions, not Jest mocks. --- <critical_requirements> ## CRITICAL: Before Using This Skill > **All code must follow project conventions in CLAUDE.md** (kebab-case, named exports, import ordering, `import type`, named constants) **(You MUST add `testID` props to every interactive element and forward them to native components -- Detox cannot find custom components without forwarded testID)** **(You MUST use `by.id()` as the primary matcher -- it is locale-agnostic, stable across UI changes, and decoupled from display text)** **(You MUST call `waitFor().withTimeout()` only as a last resort -- Detox auto-synchronizes with JS, UI, and network by default)** **(You MUST use Metro source extensions (`.mock.js` / `.e2e.js`) for mocking -- Jest mocks do not work in Detox E2E tests)** **(You MUST set a `withTimeout()` on every `waitFor` call -- calling `waitFor` without a timeout does nothing)** </critical_requirements> --- **Auto-detection:** Detox, detox, .detoxrc.js, detox.config.js, by.id, by.text, by.label, element(), expect(), waitFor, device.launchApp, device.reloadReactNative, device.terminateApp, device.disableSynchronization, testID, E2E test React Native, gray-box testing, detox test, detox build **When to use:** - Writing end-to-end tests for React Native apps on iOS and Android - Configuring Detox device, app, and artifact settings in `.detoxrc.js` - Matching elements, performing actions, and asserting expectations - Handling synchronization issues with animations or long-polling - Mocking network responses or app configuration for E2E tests - Setting up CI pipelines for automated Detox test runs - Debugging flaky tests caused by synchronization problems **Key patterns covered:** - `.detoxrc.js` configuration (devices, apps, configurations, artifacts) - Element matchers (`by.id`, `by.text`, `by.label`, compound matchers) - Actions (`tap`, `typeText`, `scroll`, `swipe`, `longPress`) - Expectations (`toBeVisible`, `toExist`, `toHaveText`, `not`) - `waitFor` with polling and `withTimeout` for manual synchronization - Device API (`launchApp`, `reloadReactNative`, `terminateApp`, biometrics) - Mocking via Metro bundler source extensions - Artifacts (screenshots, videos, logs) and CI integration - testID strategy and naming conventions **When NOT to use:** - Unit or component testing (use your project's unit test runner) - Web-only React applications (Detox is mobile-only) - Apps built with Flutter, Swift, or Kotlin (Detox is React Native focused) - Simple snapshot or render tests (use component testing tools) **Detailed Resources:** - [examples/core.md](examples/core.md) - Matchers, actions, expectations, waitFor, testID strategy - [examples/synchronization.md](examples/synchronization.md) - Animation handling, manual sync, debug synchronization - [examples/ci-artifacts.md](examples/ci-artifacts.md) - Artifacts configuration, CI workflows, mocking with Metro - [reference.md](reference.md) - Decision frameworks, matcher/action/expectation tables, checklists --- <philosophy> ## Philosophy Detox is a **gray-box** E2E testing framework -- it has internal knowledge of your app's state (JS thread idle, animations complete, network requests finished) and automatically synchronizes with it. This is what makes Detox tests dramatically less flaky than black-box alternatives that rely on arbitrary `sleep()` calls. **Core principles:** 1. **Automatic synchronization first** - Detox waits for the app to be idle before each interaction. Only use `waitFor` when auto-sync genuinely fails (looping animations, long-polling). 2. **testID is the primary matcher** - `by.id()` is stable across locale changes, text updates, and layout shifts. `by.text()` and `by.label()` are fragile fallbacks. 3. **Gray-box over black-box** - Detox can access app internals via launch arguments and Metro mocking. Use this advantage instead of fighting the framework. 4. **Mock at the boundary** - Mocking in Detox happens through Metro source extensions (`.mock.js`), not Jest mocks. The app runs for real; only external dependencies are swapped. 5. **Fail fast, debug visually** - Use artifacts (screenshots on failure, video recordings) to diagnose issues. Enable `--debug-synchronization` to find what blocks the idle loop. **Mental model:** Detox tests should read like a user script: navigate, interact, verify. The framework handles timing. If you find yourself adding manual waits, something is wrong -- either an animation loop, a long-polling connection, or a synchronization issue that should be fixed at the source. </philosophy> --- <patterns> ## Core Patterns ### Pattern 1: .detoxrc.js Configuration The config file defines devices, apps, and test configurations. Keep configs in three dictionaries: `devices`, `apps`, and `configurations`. ```javascript // .detoxrc.js -- three key dictionaries: devices, apps, configurations /** @type {import('detox').DetoxConfig} */ module.exports = { testRunner: { args: { $0: "jest", config: "e2e/jest.config.js" } }, apps: { "ios.debug": { type: "ios.app", binaryPath: "ios/build/.../MyApp.app", build: "xcodebuild ...", }, "android.debug": { type: "android.apk", binaryPath: "android/.../app-debug.apk", build: "cd android && ./gradlew ...", reversePorts: [8081], }, }, devices: { simulator: { type: "ios.simulator", device: { type: "iPhone 16" } }, emulator: { type: "android.emulator", device: { avdName: "Pixel_7_API_34" }, }, }, configurations: { "ios.sim.debug": { device: "simulator", app: "ios.debug" }, "android.emu.debug": { device: "emulator", app: "android.debug" }, }, }; ``` **Why good:** JSDoc for autocomplete, separate device/app/config concerns, `reversePorts` for Android Metro, both platforms configured See [examples/ci-artifacts.md](examples/ci-artifacts.md) for artifacts and CI-specific configuration. --- ### Pattern 2: Element Matchers Match elements using `by.id()` (preferred), `by.text()`, `by.label()`, or compound matchers. Always prefer `by.id()` -- it is locale-agnostic and decoupled from display text. ```typescript // Preferred: by.id matches testID prop element(by.id("login-button")); // Text matching (fragile -- breaks on locale change) element(by.text("Submit")); // Accessibility label element(by.label("Close dialog")); // Compound: element with id AND text element(by.id("greeting").and(by.text("Hello"))); // Hierarchy: child within parent element(by.id("item-title").withAncestor(by.id("product-list"))); // Multiple matches: select by index (0-based) element(by.text("Add to cart")).atIndex(1); ``` **Why good:** `by.id()` is decoupled from UI text, survives refactors, works across locales See [examples/core.md](examples/core.md) for full matcher examples including regex support. --- ### Pattern 3: Actions Simulate user interactions: tap, type, scroll, swipe. Actions auto-wait for the element to exist and the app to be idle. ```typescript // Tap await element(by.id("submit-btn")).tap(); // Type text (uses keyboard simulation) await element(by.id("email-input")).typeText("user@example.com"); // Replace text (no keyboard, faster) await element(by.id("search-input")).replaceText("new query"); // Clear and retype await element(by.id("name-input")).clearText(); await element(by.id("name-input")).typeText("New Name"); // Scroll down 300 points await element(by.id("scroll-view")).scroll(300, "down"); // Swipe left await element(by.id("card")).swipe("left", "fast"); ``` **Why good:** actions auto-synchronize, typeText simulates real keyboard (triggers onChangeText), replaceText is faster for pre-filling See [examples/core.md](examples/core.md) for long press, multi-tap, scroll-to-edge, and date picker actions. --- ### Pattern 4: Expectations Verify element state after interactions. Expectations also auto-synchronize. ```typescript // Visibility (default: 75% visible threshold) await expect(element(by.id("welcome-banner"))).toBeVisible(); // Existence in hierarchy (may not be visible) await expect(element(by.id("hidden-data"))).toExist(); // Text content await expect(element(by.id("greeting"))).toHaveText("Hello, World"); // Negation await expect(element(by.id("error-message"))).not.toBeVisible(); await expect(element(by.id("deleted-item"))).not.toExist(); // Toggle/switch state await expect(element(by.id("notifications-toggle"))).toHaveToggleValue(true); ``` **Why good:** auto-synchronization before assertion, `.not` for negative checks, `toBeVisible` checks actual screen visibility (not just hierarchy existence) See [examples/core.md](examples/core.md) for slider position, custom visibility threshold, and `toHaveValue`. --- ### Pattern 5: waitFor with Polling `waitFor` polls an expectation repeatedly until it passes or times out. **Every `waitFor` must have a `withTimeout()`** -- without it, the call does nothing. ```typescript const LOGIN_TIMEOUT_MS = 5000; const SCROLL_AMOUNT = 100; // Wait for element to appear (e.g., after network request) await waitFor(element(by.id("dashboard"))) .toBeVisible() .withTimeout(LOGIN_TIMEOUT_MS); // Scroll until element is visible await waitFor(element(by.id("item-42"))) .toBeVisible() .whileElement(by.id("item-list")) .scroll(SCROLL_AMOUNT, "down"); ``` **Why good:** named timeout constant, `whileElement` scrolls automatically until found, no manual sleep loops See [examples/synchronization.md](examples/synchronization.md) for when to use waitFor vs fixing synchronization. --- ### Pattern 6: Device API Control the device and app lifecycle between tests. ```typescript // Reset app state between tests beforeEach(async () => { await device.reloadReactNative(); }); // Full relaunch (slower but more thorough) beforeEach(async () => { await device.launchApp({ newInstance: true }); }); // Launch with deep link await device.launchApp({ url: "myapp://profile/123", newInstance: true }); // Launch with custom arguments (accessible via launch args in app) await device.launchApp({ launchArgs: { mockServerPort: "9090" }, }); // Background and foreground await device.sendToHome(); await device.launchApp({ newInstance: false }); // Take screenshot await device.takeScreenshot("after-login"); ``` **Why good:** `reloadReactNative` is faster than full relaunch, `newInstance: true` ensures clean state, `launchArgs` enables runtime configuration for mocking See [examples/core.md](examples/core.md) for biometrics, permissions, and `terminateApp`. --- ### Pattern 7: testID Strategy Add `testID` to every interactive element. Forward it through custom components to native components. ```tsx // Native component: testID works directly <Pressable testID="settings-btn" onPress={openSettings}> <Text>Settings</Text> </Pressable>; // Custom component: MUST forward testID to a native component interface CardProps { testID?: string; title: string; onPress: () => void; } function Card({ testID, title, onPress }: CardProps) { return ( <Pressable testID={testID} onPress={onPress}> <Text testID={testID ? `${testID}.title` : undefined}>{title}</Text> </Pressable> ); } // Usage: derived child IDs with dot notation <Card testID="product-card" title="Widget" onPress={handlePress} />; // Matches: by.id("product-card"), by.id("product-card.title") ``` **Why good:** dot-notation hierarchy, custom components forward testID, child elements get derived IDs for granular matching See [examples/core.md](examples/core.md) for naming conventions and list item testID patterns. --- ### Pattern 8: Mocking with Metro Source Extensions Detox mocking uses Metro bundler to swap module implementations. Jest mocks do not work in E2E tests. ```javascript // src/api/client.js - production export const API_URL = "https://api.production.com"; // src/api/client.mock.js - test override export * from "./client.js"; export const API_URL = "http://localhost:9090"; ``` Start Metro with mock extensions: ```bash npx react-native start --sourceExts mock.js,js,json,ts,tsx ``` **Why good:** production code unchanged, Metro resolves `.mock.js` first, test-specific behavior without conditionals in production code See [examples/ci-artifacts.md](examples/ci-artifacts.md) for environment-based Metro config and mock server patterns. </patterns> --- <decision_framework> ## Decision Framework ### Which Matcher to Use ``` Need to find an element? | +-> Has a testID? | +-> YES -> by.id("testID") (always preferred) | +-> NO -> Can you add one? | +-> YES -> Add testID, use by.id() | +-> NO -> Continue... | +-> Has unique visible text? | +-> YES -> by.text("exact text") | +-> NO -> by.label("accessibility label") | +-> Multiple matches? +-> Use .atIndex(n) or compound matchers: by.id("x").withAncestor(by.id("parent")) ``` ### When to Use waitFor vs Fix Synchronization ``` Test is flaky / element not found? | +-> Is there a looping animation? | +-> YES -> Mock the animation in tests (Metro extension) | or disable via launch arg | +-> Is there a long-polling / WebSocket connection? | +-> YES -> device.setURLBlacklist([".*long-poll.*"]) | +-> Is there a setTimeout loop? | +-> YES -> Convert to setInterval (Detox ignores setInterval) | +-> None of the above? +-> Use waitFor().toBeVisible().withTimeout(ms) +-> Enable --debug-synchronization to find the blocker ``` ### Test Lifecycle Strategy ``` How to reset between tests? | +-> Need clean JS state only? | +-> device.reloadReactNative() (fast, reloads JS bundle) | +-> Need clean app data + permissions? | +-> device.launchApp({ delete: true }) (reinstalls app) | +-> Need clean device state? +-> device.resetContentAndSettings() (full simulator reset, iOS) ``` </decision_framework> --- <red_flags> ## RED FLAGS **High Priority Issues:** - Using `sleep()` or manual delays instead of Detox auto-synchronization -- Detox waits for idle automatically; sleep masks real issues - Calling `waitFor()` without `.withTimeout()` -- does nothing, silently passes without waiting - Using `by.text()` as the primary matcher -- breaks on locale changes and text updates; use `by.id()` - Adding `testID` to a custom component without forwarding to a native component -- Detox cannot find it - Using Jest mocks (`jest.mock()`) in Detox tests -- E2E tests run in the app process, not Jest; use Metro source extensions **Medium Priority Issues:** - Using `device.launchApp({ delete: true })` in every `beforeEach` -- extremely slow; use `reloadReactNative()` unless you need clean storage - Not using `--record-videos failing` and `--take-screenshots failing` in CI -- makes debugging failed CI tests impossible - Hardcoded timeout values -- use named constants (`LOGIN_TIMEOUT_MS`, not `5000`) - Using `by.type()` for matching -- platform-specific class names differ between iOS and Android **Gotchas & Edge Cases:** - `toBeVisible()` checks 75% screen visibility by default -- an element can `toExist()` but not `toBeVisible()` if it is offscreen or obscured - `typeText()` requires the element to be focused first on some platforms -- tap the input before typing if `typeText` fails - `by.traits()` is iOS only -- no Android equivalent exists - `reloadReactNative()` does not clear AsyncStorage, MMKV, or other persistent storage -- use `launchApp({ delete: true })` for that - Looping animations (spinners, pulse effects) block Detox synchronization indefinitely -- mock them or use `disableSynchronization()` + `waitFor` - `setURLBlacklist` accepts an array of regex strings, not plain URLs -- escape dots and slashes properly - Android emulator tests need `reversePorts: [8081]` in the app config or Metro bundler is unreachable - `device.disableSynchronization()` is global -- always re-enable with `device.enableSynchronization()` in an `afterEach` or `finally` block - `getAttributes()` returns different shapes on iOS vs Android -- check platform before accessing specific fields - FlashList/FlatList items may not have `testID` accessible until scrolled into view -- use `waitFor().whileElement().scroll()` pattern </red_flags> --- <critical_reminders> ## CRITICAL REMINDERS > **All code must follow project conventions in CLAUDE.md** **(You MUST add `testID` props to every interactive element and forward them to native components -- Detox cannot find custom components without forwarded testID)** **(You MUST use `by.id()` as the primary matcher -- it is locale-agnostic, stable across UI changes, and decoupled from display text)** **(You MUST call `waitFor().withTimeout()` only as a last resort -- Detox auto-synchronizes with JS, UI, and network by default)** **(You MUST use Metro source extensions (`.mock.js` / `.e2e.js`) for mocking -- Jest mocks do not work in Detox E2E tests)** **(You MUST set a `withTimeout()` on every `waitFor` call -- calling `waitFor` without a timeout does nothing)** **Failure to follow these rules will produce flaky tests, unmatchable elements, and silent test passes that verify nothing.** </critical_reminders>
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.