detox-mobile-test

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Detox 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 out
javascript
// 点击
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 --cleanup
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 --cleanup

Performance 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,
  // ...
};

Лучшие практики

最佳实践

  1. Stable selectors — используйте testID, не text
  2. Proper waits — waitFor вместо sleep
  3. Page Objects — переиспользуемые абстракции
  4. Isolated tests — каждый тест независим
  5. CI/CD first — тесты должны работать в CI
  6. Record on failure — видео/скриншоты при падении
  1. 稳定选择器 — 使用testID,而非文本
  2. 合理等待 — 使用waitFor替代sleep
  3. Page Objects — 可复用的UI抽象层
  4. 测试隔离 — 每个测试独立运行
  5. 优先支持CI/CD — 测试需能在CI环境中正常运行
  6. 失败时记录 — 测试失败时自动录制视频/截图