Detox 完整入門 — React Native 自動化神器
「我們 RN app 用 Appium 跑、每個 case 20 秒」 → 你浪費了 80% 時間。Detox 為 RN 量身打造、灰盒測試、自動 sync app idle,同 case 跑 4 秒。這篇給你 0 到 CI 完整 setup。
為什麼 Detox 比 Appium 快
flowchart LR
A[Appium 黑盒] --> AP[透過 WebDriver]
AP --> AS[跨 process 通訊]
AS --> AT["需自己 wait<br>每動作 sleep / retry"]
AT --> Slow["20s / case"]
D[Detox 灰盒] --> DI[直接注入 RN process]
DI --> DS[Auto-sync app idle state]
DS --> DT["JS thread idle → 立刻動<br>不用 sleep"]
DT --> Fast["4s / case"]
style Slow fill:#ef4444,color:#fff
style Fast fill:#10b981,color:#fff
核心:Detox 知道 RN「現在閒了」、立刻執行下一動作;Appium 只能 sleep + retry。
30 分鐘 0 到綠 CI
Setup
# 1. Detox CLI
npm install -g detox-cli
# 2. 專案內裝
cd MyRNApp
npm install -D detox jest @types/jest
# 3. Init
detox init
會建:
- .detoxrc.js — 設定檔
- e2e/jest.config.js — Jest 配置
- e2e/starter.test.js — 範例 test
.detoxrc.js 配置
module.exports = {
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 15' } },
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' },
},
};
第一個 test
// e2e/login.test.js
describe('Login flow', () => {
beforeAll(async () => {
await device.launchApp();
});
beforeEach(async () => {
await device.reloadReactNative();
});
it('登入成功跳轉 Home', async () => {
await element(by.id('email-input')).typeText('[email protected]');
await element(by.id('password-input')).typeText('Test@123');
await element(by.id('login-button')).tap();
await expect(element(by.text('Welcome'))).toBeVisible();
});
it('錯密碼顯示錯誤', async () => {
await element(by.id('email-input')).typeText('[email protected]');
await element(by.id('password-input')).typeText('wrong');
await element(by.id('login-button')).tap();
await expect(element(by.text('帳號或密碼錯誤'))).toBeVisible();
});
});
跑
# Build
detox build -c ios.sim.debug
# Test
detox test -c ios.sim.debug
# Android
detox build -c android.emu.debug
detox test -c android.emu.debug
Selector 哲學
flowchart LR
Sel[Detox Selector] --> S1["by.id - 推薦"]
Sel --> S2[by.text]
Sel --> S3[by.label - accessibility]
Sel --> S4[by.type - native type]
Sel --> S5["by.traits - iOS only"]
S1 --> Best[testID prop on RN element]
S2 --> OK[文字 - 多語會跨]
S3 --> A11y[最 accessible]
style S1 fill:#10b981,color:#fff
前端需要配合加 testID:
<TouchableOpacity testID="login-button" onPress={login}>
<Text>登入</Text>
</TouchableOpacity>
<TextInput testID="email-input" value={email} onChangeText={setEmail} />
Auto-sync — Detox 的殺手鐧
flowchart TD
Action[element.tap] --> Wait[Detox 等]
Wait --> Check{App 各 thread idle?}
Check --> C1[JS thread]
Check --> C2[Native UI thread]
Check --> C3[Network requests]
Check --> C4[Animations]
Check --> C5[Timers]
Check -->|全 idle| Go[執行 tap]
Check -->|有忙| Wait
style Go fill:#10b981,color:#fff
不用 sleep、不用 waitFor。Detox 自動等到 app idle 才動。
例外:手動 sync 控制
// 暫停 sync(要測 loading state)
await device.disableSynchronization();
await element(by.id('submit')).tap();
await expect(element(by.id('spinner'))).toBeVisible();
await device.enableSynchronization();
Mocking — Network / Module
Mock 網路請求
// 用 reactotron-mocks 或自寫 middleware
beforeEach(async () => {
await device.launchApp({
launchArgs: { MOCK_API: 'true' },
});
});
// app 端
if (Config.MOCK_API) {
api.interceptors.response.use(req => mockResponses[req.url]);
}
Mock Native Module
// e2e/mocks/Permissions.js
export default {
request: () => Promise.resolve('granted'),
check: () => Promise.resolve('granted'),
};
Page Object Model 適配
// e2e/pages/LoginPage.js
class LoginPage {
emailInput = () => element(by.id('email-input'));
passwordInput = () => element(by.id('password-input'));
submitBtn = () => element(by.id('login-button'));
errorText = () => element(by.text('帳號或密碼錯誤'));
async login(email, password) {
await this.emailInput().typeText(email);
await this.passwordInput().typeText(password);
await this.submitBtn().tap();
}
}
export default new LoginPage();
// e2e/login.test.js
import LoginPage from './pages/LoginPage';
it('login success', async () => {
await LoginPage.login('[email protected]', 'Test@123');
await expect(element(by.text('Welcome'))).toBeVisible();
});
CI 整合 — GitHub Actions
iOS(macOS runner)
name: E2E iOS
on: [pull_request]
jobs:
ios:
runs-on: macos-14
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20 }
- run: npm ci
- name: Pod install
run: cd ios && pod install
- name: Build Detox
run: detox build -c ios.sim.debug
- name: Run Detox
run: detox test -c ios.sim.debug --headless --record-logs all
- if: failure()
uses: actions/upload-artifact@v4
with:
name: detox-artifacts-ios
path: artifacts/
Android(Ubuntu runner)
android:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20 }
- uses: actions/setup-java@v4
with: { distribution: 'temurin', java-version: '17' }
- run: npm ci
- name: AVD cache
uses: actions/cache@v4
with:
path: |
~/.android/avd/*
~/.android/adb*
key: avd-pixel-7-api-34
- name: Build Detox
run: detox build -c android.emu.debug
- name: Run Detox in emulator
uses: reactivecircus/android-emulator-runner@v2
with:
api-level: 34
target: google_apis
arch: x86_64
profile: pixel_7
script: detox test -c android.emu.debug --headless
跟 Firebase Test Lab 整合
Detox build 出來的 APK 可以上傳 FTL 跑跨真機:
# 1. Build instrumented APK + test APK
detox build -c android.emu.debug
# 2. 上傳 FTL
gcloud firebase test android run \
--type instrumentation \
--app android/app/build/outputs/apk/debug/app-debug.apk \
--test android/app/build/outputs/apk/androidTest/debug/app-debug-androidTest.apk \
--device model=Pixel7,version=34 \
--device model=SamsungGalaxyS22,version=33
local Detox 快 debug + FTL 跨真機驗證。延伸:Firebase Test Lab 完整指南
常見 7 個坑
1. iOS Simulator 抓不到 testID
原因:accessibility 沒 enable。 解:iOS Settings → Accessibility → 開 VoiceOver 一次(之後可關)。
2. typeText 中文字輸不進去
原因:Detox 用 native keyboard、不支援 IME。
解:用 replaceText 取代 typeText。
await element(by.id('input')).replaceText('你好世界');
3. 動畫導致 sync timeout
原因:CSS animation 或 native animation 永遠不 idle。
解:手動 device.disableSynchronization() 或在 dev mode 關閉動畫。
4. WebView 內容測不到
原因:Detox 不直接支援 WebView。 解:用 web fallback 或 switch context(有限支援)。
5. CI emulator 啟動慢
原因:cold boot 每次 5+ 分鐘。 解:cache AVD(如上面 yml)+ 用 quick boot snapshot。
6. Flaky test 集中在某 modal
原因:modal animation 沒結束就互動。
解:用 waitFor(element).toBeVisible().withTimeout(5000) 顯式等。
7. Memory leak 跑 100 個 test 後 OOM
原因:每 test 沒 reload。
解:beforeEach: await device.reloadReactNative();
Detox vs Appium 對比
| 維度 | Detox | Appium |
|---|---|---|
| 速度 | ⚡ 快 5-10x | 慢 |
| 支援框架 | RN(主)/ Native | 任何 |
| 跨平台 code | 同一份 | 同一份(但細節差) |
| Auto-sync | ✓ 內建 | ✗ 要手動 wait |
| 真機 | iOS 部分、Android 需設定 | 完整 |
| WebView | 弱 | 強 |
| 學習曲線 | 中 | 高(環境設定) |
| 社群 | 中(RN 用戶) | 大(業界標準) |
延伸:Mobile App Testing 入門(Appium vs Detox)
反模式
flowchart TD
Anti[Detox 反模式] --> A1["每 test 不 reload → state 污染"]
Anti --> A2["大量 typeText 中文 → IME 衝突"]
Anti --> A3["未配合 dev 加 testID"]
Anti --> A4["Sync 不關就測 loading"]
Anti --> A5["跑 prod build (沒 debug symbols)"]
Anti --> A6["CI 不 cache AVD"]
Anti --> A7["真機跑 Detox(部分不支援)"]
style A1 fill:#ef4444,color:#fff
style A2 fill:#ef4444,color:#fff
style A3 fill:#ef4444,color:#fff
style A4 fill:#ef4444,color:#fff
style A5 fill:#ef4444,color:#fff
style A6 fill:#ef4444,color:#fff
style A7 fill:#ef4444,color:#fff
給 RN QA 的 5 句
- RN 就選 Detox、別碰 Appium 浪費生命
- 跟 dev 配合 testID = 一勞永逸
- Sync 是神功、別輕易關
- Local Detox + FTL = 完整 Android 覆蓋
- CI 一定 cache AVD、不然每次 5 分鐘 boot
最後
Detox 是 React Native QA 的最佳投資。從 setup 到第一個綠 CI 30 分鐘、之後每個 test 比 Appium 省 70% 時間。一年累積省下的時間夠你深入學 5 個新主題。
延伸: - Mobile App Testing 入門(Appium vs Detox) - Firebase Test Lab 完整指南 - Page Object Model 實戰