detox-mobile-test
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseDetox Mobile Testing Expert
Detox移动端测试专家
Эксперт по E2E тестированию React Native приложений с Detox.
专注于使用Detox进行React Native应用的端到端(E2E)测试专家。
Core Testing Principles
核心测试原则
Synchronization
同步机制
- Автоматическая синхронизация с React Native bridge
- Синхронизация с анимациями и сетевыми запросами
- для явных ожиданий
waitFor() - вместо
toBeVisible()для стабильностиtoExist()
- 与React Native bridge自动同步
- 与动画和网络请求同步
- 使用实现显式等待
waitFor() - 为保证稳定性,使用替代
toBeVisible()toExist()
Test Organization
测试组织方式
- AAA pattern (Arrange, Act, Assert)
- Изоляция через и
beforeEach()afterEach() - для группировки
describe() - Page Object pattern для сложного UI
- AAA模式(Arrange准备、Act执行、Assert断言)
- 通过和
beforeEach()实现测试隔离afterEach() - 使用进行测试分组
describe() - 针对复杂UI使用Page Object模式
Configuration
配置
.detoxrc.json
.detoxrc.json
json
{
"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"
},
"ios.release": {
"type": "ios.app",
"binaryPath": "ios/build/Build/Products/Release-iphonesimulator/MyApp.app",
"build": "xcodebuild -workspace ios/MyApp.xcworkspace -scheme MyApp -configuration Release -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"
},
"android.release": {
"type": "android.apk",
"binaryPath": "android/app/build/outputs/apk/release/app-release.apk",
"build": "cd android && ./gradlew assembleRelease assembleAndroidTest -DtestBuildType=release"
}
},
"devices": {
"simulator": {
"type": "ios.simulator",
"device": { "type": "iPhone 14" }
},
"emulator": {
"type": "android.emulator",
"device": { "avdName": "Pixel_4_API_30" }
}
},
"configurations": {
"ios.sim.debug": {
"device": "simulator",
"app": "ios.debug"
},
"ios.sim.release": {
"device": "simulator",
"app": "ios.release"
},
"android.emu.debug": {
"device": "emulator",
"app": "android.debug"
},
"android.emu.release": {
"device": "emulator",
"app": "android.release"
}
}
}json
{
"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"
},
"ios.release": {
"type": "ios.app",
"binaryPath": "ios/build/Build/Products/Release-iphonesimulator/MyApp.app",
"build": "xcodebuild -workspace ios/MyApp.xcworkspace -scheme MyApp -configuration Release -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"
},
"android.release": {
"type": "android.apk",
"binaryPath": "android/app/build/outputs/apk/release/app-release.apk",
"build": "cd android && ./gradlew assembleRelease assembleAndroidTest -DtestBuildType=release"
}
},
"devices": {
"simulator": {
"type": "ios.simulator",
"device": { "type": "iPhone 14" }
},
"emulator": {
"type": "android.emulator",
"device": { "avdName": "Pixel_4_API_30" }
}
},
"configurations": {
"ios.sim.debug": {
"device": "simulator",
"app": "ios.debug"
},
"ios.sim.release": {
"device": "simulator",
"app": "ios.release"
},
"android.emu.debug": {
"device": "emulator",
"app": "android.debug"
},
"android.emu.release": {
"device": "emulator",
"app": "android.release"
}
}
}Jest Config
Jest配置
javascript
// e2e/jest.config.js
module.exports = {
rootDir: '..',
testMatch: ['<rootDir>/e2e/**/*.test.js'],
testTimeout: 120000,
maxWorkers: 1,
globalSetup: 'detox/runners/jest/globalSetup',
globalTeardown: 'detox/runners/jest/globalTeardown',
reporters: ['detox/runners/jest/reporter'],
testEnvironment: 'detox/runners/jest/testEnvironment',
verbose: true
};javascript
// e2e/jest.config.js
module.exports = {
rootDir: '..',
testMatch: ['<rootDir>/e2e/**/*.test.js'],
testTimeout: 120000,
maxWorkers: 1,
globalSetup: 'detox/runners/jest/globalSetup',
globalTeardown: 'detox/runners/jest/globalTeardown',
reporters: ['detox/runners/jest/reporter'],
testEnvironment: 'detox/runners/jest/testEnvironment',
verbose: true
};Basic Test Structure
基础测试结构
javascript
describe('Login Flow', () => {
beforeAll(async () => {
await device.launchApp();
});
beforeEach(async () => {
await device.reloadReactNative();
});
afterAll(async () => {
await device.terminateApp();
});
it('should login with valid credentials', async () => {
// Arrange
const email = 'test@example.com';
const password = 'password123';
// Act
await element(by.id('email-input')).typeText(email);
await element(by.id('password-input')).typeText(password);
await element(by.id('login-button')).tap();
// Assert
await expect(element(by.id('home-screen'))).toBeVisible();
});
it('should show error for invalid credentials', async () => {
// Arrange
const email = 'wrong@example.com';
const password = 'wrongpassword';
// Act
await element(by.id('email-input')).typeText(email);
await element(by.id('password-input')).typeText(password);
await element(by.id('login-button')).tap();
// Assert
await expect(element(by.id('error-message'))).toBeVisible();
await expect(element(by.text('Invalid credentials'))).toBeVisible();
});
});javascript
describe('登录流程', () => {
beforeAll(async () => {
await device.launchApp();
});
beforeEach(async () => {
await device.reloadReactNative();
});
afterAll(async () => {
await device.terminateApp();
});
it('使用有效凭据登录成功', async () => {
// 准备
const email = 'test@example.com';
const password = 'password123';
// 执行
await element(by.id('email-input')).typeText(email);
await element(by.id('password-input')).typeText(password);
await element(by.id('login-button')).tap();
// 断言
await expect(element(by.id('home-screen'))).toBeVisible();
});
it('无效凭据时显示错误信息', async () => {
// 准备
const email = 'wrong@example.com';
const password = 'wrongpassword';
// 执行
await element(by.id('email-input')).typeText(email);
await element(by.id('password-input')).typeText(password);
await element(by.id('login-button')).tap();
// 断言
await expect(element(by.id('error-message'))).toBeVisible();
await expect(element(by.text('Invalid credentials'))).toBeVisible();
});
});Element Matchers
元素匹配器
javascript
// By testID
element(by.id('submit-button'))
// By text
element(by.text('Submit'))
// By label (accessibility)
element(by.label('Submit form'))
// By type
element(by.type('RCTTextInput'))
// By traits (iOS)
element(by.traits(['button']))
// Combining matchers
element(by.id('item').withAncestor(by.id('list')))
element(by.id('item').withDescendant(by.text('Title')))
// Index for multiple matches
element(by.id('list-item')).atIndex(0)javascript
// 通过testID匹配
element(by.id('submit-button'))
// 通过文本匹配
element(by.text('Submit'))
// 通过标签(无障碍属性)匹配
element(by.label('Submit form'))
// 通过类型匹配
element(by.type('RCTTextInput'))
// 通过特征(iOS)匹配
element(by.traits(['button']))
// 组合匹配器
element(by.id('item').withAncestor(by.id('list')))
element(by.id('item').withDescendant(by.text('Title')))
// 多个匹配结果时通过索引选择
element(by.id('list-item')).atIndex(0)Actions
操作方法
javascript
// Tap
await element(by.id('button')).tap();
await element(by.id('button')).multiTap(2);
await element(by.id('button')).longPress();
await element(by.id('button')).longPress(2000); // 2 seconds
// Text input
await element(by.id('input')).typeText('Hello');
await element(by.id('input')).replaceText('New text');
await element(by.id('input')).clearText();
// Scroll
await element(by.id('scrollView')).scroll(200, 'down');
await element(by.id('scrollView')).scroll(200, 'up');
await element(by.id('scrollView')).scrollTo('bottom');
await element(by.id('scrollView')).scrollTo('top');
// Scroll until visible
await waitFor(element(by.id('item')))
.toBeVisible()
.whileElement(by.id('scrollView'))
.scroll(200, 'down');
// Swipe
await element(by.id('card')).swipe('left');
await element(by.id('card')).swipe('right', 'fast', 0.9);
// Pinch
await element(by.id('map')).pinch(1.5); // zoom in
await element(by.id('map')).pinch(0.5); // zoom outjavascript
// 点击
await element(by.id('button')).tap();
await element(by.id('button')).multiTap(2);
await element(by.id('button')).longPress();
await element(by.id('button')).longPress(2000); // 2秒
// 文本输入
await element(by.id('input')).typeText('Hello');
await element(by.id('input')).replaceText('New text');
await element(by.id('input')).clearText();
// 滚动
await element(by.id('scrollView')).scroll(200, 'down');
await element(by.id('scrollView')).scroll(200, 'up');
await element(by.id('scrollView')).scrollTo('bottom');
await element(by.id('scrollView')).scrollTo('top');
// 滚动直到元素可见
await waitFor(element(by.id('item')))
.toBeVisible()
.whileElement(by.id('scrollView'))
.scroll(200, 'down');
// 滑动
await element(by.id('card')).swipe('left');
await element(by.id('card')).swipe('right', 'fast', 0.9);
// 捏合缩放
await element(by.id('map')).pinch(1.5); // 放大
await element(by.id('map')).pinch(0.5); // 缩小Expectations
断言方法
javascript
// Visibility
await expect(element(by.id('view'))).toBeVisible();
await expect(element(by.id('view'))).not.toBeVisible();
await expect(element(by.id('view'))).toExist();
await expect(element(by.id('view'))).not.toExist();
// Focus
await expect(element(by.id('input'))).toBeFocused();
// Text
await expect(element(by.id('label'))).toHaveText('Hello');
await expect(element(by.id('input'))).toHaveValue('input value');
// Toggle state
await expect(element(by.id('switch'))).toHaveToggleValue(true);
// Slider
await expect(element(by.id('slider'))).toHaveSliderPosition(0.5);
// ID
await expect(element(by.id('view'))).toHaveId('view');
// Label
await expect(element(by.id('button'))).toHaveLabel('Submit');javascript
// 可见性断言
await expect(element(by.id('view'))).toBeVisible();
await expect(element(by.id('view'))).not.toBeVisible();
await expect(element(by.id('view'))).toExist();
await expect(element(by.id('view'))).not.toExist();
// 焦点断言
await expect(element(by.id('input'))).toBeFocused();
// 文本断言
await expect(element(by.id('label'))).toHaveText('Hello');
await expect(element(by.id('input'))).toHaveValue('input value');
// 开关状态断言
await expect(element(by.id('switch'))).toHaveToggleValue(true);
// 滑块位置断言
await expect(element(by.id('slider'))).toHaveSliderPosition(0.5);
// ID断言
await expect(element(by.id('view'))).toHaveId('view');
// 标签断言
await expect(element(by.id('button'))).toHaveLabel('Submit');waitFor API
waitFor API
javascript
// Wait for element to be visible
await waitFor(element(by.id('loading')))
.not.toBeVisible()
.withTimeout(10000);
// Wait for element to exist
await waitFor(element(by.id('data')))
.toExist()
.withTimeout(5000);
// Wait while scrolling
await waitFor(element(by.id('item-50')))
.toBeVisible()
.whileElement(by.id('list'))
.scroll(100, 'down');
// Custom polling
await waitFor(element(by.id('result')))
.toHaveText('Success')
.withTimeout(30000);javascript
// 等待元素消失
await waitFor(element(by.id('loading')))
.not.toBeVisible()
.withTimeout(10000);
// 等待元素出现
await waitFor(element(by.id('data')))
.toExist()
.withTimeout(5000);
// 滚动时等待
await waitFor(element(by.id('item-50')))
.toBeVisible()
.whileElement(by.id('list'))
.scroll(100, 'down');
// 自定义轮询等待
await waitFor(element(by.id('result')))
.toHaveText('Success')
.withTimeout(30000);Page Object Pattern
Page Object模式
javascript
// e2e/pages/LoginPage.js
class LoginPage {
get emailInput() {
return element(by.id('email-input'));
}
get passwordInput() {
return element(by.id('password-input'));
}
get loginButton() {
return element(by.id('login-button'));
}
get errorMessage() {
return element(by.id('error-message'));
}
async login(email, password) {
await this.emailInput.typeText(email);
await this.passwordInput.typeText(password);
await this.loginButton.tap();
}
async assertErrorVisible(message) {
await expect(this.errorMessage).toBeVisible();
if (message) {
await expect(element(by.text(message))).toBeVisible();
}
}
}
module.exports = new LoginPage();
// e2e/tests/login.test.js
const LoginPage = require('../pages/LoginPage');
const HomePage = require('../pages/HomePage');
describe('Login', () => {
it('should login successfully', async () => {
await LoginPage.login('user@test.com', 'password123');
await expect(HomePage.welcomeMessage).toBeVisible();
});
});javascript
// e2e/pages/LoginPage.js
class LoginPage {
get emailInput() {
return element(by.id('email-input'));
}
get passwordInput() {
return element(by.id('password-input'));
}
get loginButton() {
return element(by.id('login-button'));
}
get errorMessage() {
return element(by.id('error-message'));
}
async login(email, password) {
await this.emailInput.typeText(email);
await this.passwordInput.typeText(password);
await this.loginButton.tap();
}
async assertErrorVisible(message) {
await expect(this.errorMessage).toBeVisible();
if (message) {
await expect(element(by.text(message))).toBeVisible();
}
}
}
module.exports = new LoginPage();
// e2e/tests/login.test.js
const LoginPage = require('../pages/LoginPage');
const HomePage = require('../pages/HomePage');
describe('登录', () => {
it('登录成功', async () => {
await LoginPage.login('user@test.com', 'password123');
await expect(HomePage.welcomeMessage).toBeVisible();
});
});Debugging
调试
Verbose Logging
详细日志
javascript
// In test
await device.launchApp({
launchArgs: {
detoxPrintBusyIdleResources: 'YES'
}
});javascript
// 在测试中
await device.launchApp({
launchArgs: {
detoxPrintBusyIdleResources: 'YES'
}
});Screenshots
截图
javascript
// Take screenshot
await device.takeScreenshot('login-screen');
// On failure (in jest setup)
afterEach(async () => {
if (jasmine.currentTest.failedExpectations.length > 0) {
await device.takeScreenshot(`failed-${jasmine.currentTest.fullName}`);
}
});javascript
// 截取屏幕截图
await device.takeScreenshot('login-screen');
// 测试失败时自动截图(在Jest配置中)
afterEach(async () => {
if (jasmine.currentTest.failedExpectations.length > 0) {
await device.takeScreenshot(`failed-${jasmine.currentTest.fullName}`);
}
});Element Debugging
元素调试
javascript
// Get element attributes
const attributes = await element(by.id('button')).getAttributes();
console.log(attributes);
// { text: 'Submit', visible: true, enabled: true, ... }javascript
// 获取元素属性
const attributes = await element(by.id('button')).getAttributes();
console.log(attributes);
// { text: 'Submit', visible: true, enabled: true, ... }Handling Common Issues
常见问题处理
Disable Synchronization
禁用同步
javascript
// For non-React Native screens (WebViews, etc.)
await device.disableSynchronization();
await element(by.id('webview-button')).tap();
await device.enableSynchronization();javascript
// 针对非React Native屏幕(如WebViews等)
await device.disableSynchronization();
await element(by.id('webview-button')).tap();
await device.enableSynchronization();Permission Dialogs
权限弹窗
javascript
// iOS
await device.launchApp({
permissions: {
notifications: 'YES',
camera: 'YES',
photos: 'YES',
location: 'always'
}
});
// Android - handle at runtime
await element(by.text('Allow')).tap();javascript
// iOS
await device.launchApp({
permissions: {
notifications: 'YES',
camera: 'YES',
photos: 'YES',
location: 'always'
}
});
// Android - 运行时处理
await element(by.text('Allow')).tap();Keyboard Issues
键盘问题
javascript
// Dismiss keyboard
await element(by.id('input')).typeText('text\n');
// or
await device.pressBack(); // Android
// Avoid keyboard overlap
await element(by.id('input')).tap();
await element(by.id('input')).typeText('text');
await element(by.id('submit')).tap();javascript
// 收起键盘
await element(by.id('input')).typeText('text\n');
// 或
await device.pressBack(); // Android
// 避免键盘遮挡
await element(by.id('input')).tap();
await element(by.id('input')).typeText('text');
await element(by.id('submit')).tap();CI/CD Integration
CI/CD集成
GitHub Actions
GitHub Actions
yaml
name: E2E Tests
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
ios-e2e:
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '18'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Install pods
run: cd ios && pod install
- name: Build app
run: npx detox build --configuration ios.sim.release
- name: Run tests
run: npx detox test --configuration ios.sim.release --cleanup
- name: Upload artifacts
if: failure()
uses: actions/upload-artifact@v3
with:
name: detox-artifacts
path: artifacts/
android-e2e:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '18'
- name: Setup Java
uses: actions/setup-java@v3
with:
distribution: 'zulu'
java-version: '11'
- name: Install dependencies
run: npm ci
- name: Build app
run: npx detox build --configuration android.emu.release
- name: Start emulator
uses: reactivecircus/android-emulator-runner@v2
with:
api-level: 30
target: google_apis
script: npx detox test --configuration android.emu.release --cleanupyaml
name: E2E Tests
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
ios-e2e:
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '18'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Install pods
run: cd ios && pod install
- name: Build app
run: npx detox build --configuration ios.sim.release
- name: Run tests
run: npx detox test --configuration ios.sim.release --cleanup
- name: Upload artifacts
if: failure()
uses: actions/upload-artifact@v3
with:
name: detox-artifacts
path: artifacts/
android-e2e:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '18'
- name: Setup Java
uses: actions/setup-java@v3
with:
distribution: 'zulu'
java-version: '11'
- name: Install dependencies
run: npm ci
- name: Build app
run: npx detox build --configuration android.emu.release
- name: Start emulator
uses: reactivecircus/android-emulator-runner@v2
with:
api-level: 30
target: google_apis
script: npx detox test --configuration android.emu.release --cleanupPerformance Tips
性能优化技巧
javascript
// Use reloadReactNative instead of launchApp
beforeEach(async () => {
await device.reloadReactNative(); // Fast
// await device.launchApp({ newInstance: true }); // Slow
});
// Record videos only on failure
// In detoxrc.json
{
"artifacts": {
"plugins": {
"video": {
"enabled": true,
"keepOnlyFailedTestsArtifacts": true
}
}
}
}
// Test sharding for parallel execution
// jest.config.js
module.exports = {
maxWorkers: process.env.CI ? 2 : 1,
// ...
};javascript
// 使用reloadReactNative替代launchApp
beforeEach(async () => {
await device.reloadReactNative(); // 快速
// await device.launchApp({ newInstance: true }); // 缓慢
});
// 仅在测试失败时录制视频
// 在detoxrc.json中
{
"artifacts": {
"plugins": {
"video": {
"enabled": true,
"keepOnlyFailedTestsArtifacts": true
}
}
}
}
// 测试分片以实现并行执行
// jest.config.js
module.exports = {
maxWorkers: process.env.CI ? 2 : 1,
// ...
};Лучшие практики
最佳实践
- Stable selectors — используйте testID, не text
- Proper waits — waitFor вместо sleep
- Page Objects — переиспользуемые абстракции
- Isolated tests — каждый тест независим
- CI/CD first — тесты должны работать в CI
- Record on failure — видео/скриншоты при падении
- 稳定选择器 — 使用testID,而非文本
- 合理等待 — 使用waitFor替代sleep
- Page Objects — 可复用的UI抽象层
- 测试隔离 — 每个测试独立运行
- 优先支持CI/CD — 测试需能在CI环境中正常运行
- 失败时记录 — 测试失败时自动录制视频/截图