Home
Perfecto的头像

Perfecto

React Native + Detox:架构设计与 P00 页面巡检实践

AI 速览

这篇文章是系列综述,讲述了如何从零开始为一个拥有 300+ 页面的 React Native 应用构建完整的 E2E 自动化测试体系。文章的亮点在于提出了 Detox + CDP 混合架构 etox 擅长 UI 断言但无法操作 App 内部状态,CDP 可以执行任意 JS 代码但缺乏 UI 测试能力,两者结合实现了”灰盒测试”的最佳实践。另一个核心设计是 双层防护体系决定测试是否通过,视觉监控只作为警示而不阻塞 CI,避免了截图对比的高误报率问题。最终实现了 300+ 页面的自动化巡检,单次执行约 30 分钟,登录操作从 10 秒优化到 3 秒。


系列文章导航


术语表

术语
全称
说明
CDP
Chrome DevTools Protocol
Chrome 开发者工具使用的调试协议,Hermes 引擎支持此协议
Hermes
-
Facebook 为 React Native 优化的轻量级 JavaScript 引擎
Metro
Metro Bundler
React Native 的开发服务器,在 8081 端口暴露 CDP 代理接口
P00
Priority 0
最高优先级测试套件,快速发现阻塞性问题
Deep Link
-
通过 URL Scheme 直接打开 App 特定页面的机制
灰盒测试
Gray Box Testing
结合黑盒和白盒测试特点,了解内部结构但主要通过外部接口测试

背景:为什么需要 E2E 测试?

MoeGo 移动端是一款面向宠物美容行业的 SaaS 应用,包含 40 个业务模块、300+ 页面。随着业务快速迭代,几个问题逐渐凸显:

  1. 回归测试成本高次发版前需要人工验证核心流程,耗时且容易遗漏
  2. 隐性 Bug 频发个模块的修改可能影响其他页面,缺乏自动化手段发现
  3. 视觉问题难追踪 I 变更是否符合预期,依赖人眼判断

E2E(End-to-End)测试是解决这类问题的有效手段。但在 React Native 场景下,传统的 E2E 方案并不完美,需要针对性的架构设计。

本文记录了从技术选型到落地实践的完整过程,重点分享三个核心设计:

  • Detox + CDP 混合架构破纯 UI 测试的局限
  • P00 页面巡检体系层防护(健康检查 + 视觉监控)
  • 可靠的同步机制决 Hermes 引擎的 CDP 兼容性问题

环境与技术栈

组件
版本
用途
React Native
0.74.5
应用框架
Expo SDK
~51.0.39
开发工具链
Detox
20.42.0
灰盒测试框架
Hermes
内置于 RN 0.74
JavaScript 引擎
Metro Bundler
内置
开发服务器 + CDP 代理
chrome-remote-interface
依赖版本
Node.js CDP 客户端
odiff-bin
最新版
像素级图片对比工具

测试规模 00+ 页面自动化巡检,涵盖 40 个业务模块


架构设计思路

技术选型:为什么选择 Detox?

针对 React Native E2E 测试需求,业界有多个成熟方案。选型时考虑三个核心因素:

  1. 录制工具否快速生成测试用例?
  2. 开发语言否支持 JS/TS?(前端团队更熟悉)
  3. 用例语法否简洁易读?(类似 Playwright 的 API 风格)
框架
录制工具
开发语言
用例语法
Appium
✅ Appium Inspector
✅ 多语言
😐 XPath + 低级操作
Espresso
✅ Android Studio
😐 Java(仅 Android)

XCUITest
✅ Xcode UI Test Recorder
😐 Objective-C(仅 iOS)

Detox
✅ DetoxRecorder
✅ JavaScript
✅ Playwright 风格

Detox 在各维度都符合需求。虽然 DetoxRecorder 已四年未更新,但经过简单修复后仍可用于 iOS 录制。录制产物可通过翻译函数适配 Android。

选择 Detox 的另一个核心原因是其自动同步机制能监控应用内部的异步操作(网络请求、动画、JS 线程任务),自动等待完成后再执行断言。这从根本上减少了 E2E 测试的不稳定性(Flaky Tests)。

Detox 官方定义:“Detox is a gray-box E2E testing framework for mobile apps”

痛点:纯 Detox 的局限性

然而,Detox 的 Native API(element().tap()waitFor())只能操作 UI 元素,无法直接访问应用内部状态。在实际使用中,遇到了以下瓶颈:

场景
Detox 原生方案
实际痛点
自动登录
逐个填写表单字段
耗时 5-10 秒;键盘弹出、输入法切换导致不稳定
页面导航
逐级点击菜单
Drawer 页面需复杂手势模拟,300+ 页面巡检变得极慢
检测 Toast
通过视觉识别文本
Toast 2 秒后自动消失,时序难控制
清理 UI 状态
模拟点击空白区域
无法彻底清除输入框焦点、隐藏键盘

这些问题的本质是:Detox 站在 UI 层外部,无法直接触达应用内部

解决方案:引入 CDP 实现”内外结合”

Chrome DevTools Protocol (CDP) Chrome 开发者工具使用的调试协议。React Native 的 Hermes 引擎支持 CDP,这意味着可以通过 CDP 直接在 App 的 JavaScript 运行时中执行代码。

这打开了一扇新的大门:

  • 绕过 UI 直接登录用 Redux action 设置登录状态,从 10 秒优化到 1 秒
  • 精准导航接调用 React Navigation API,跳过菜单点击
  • 可靠的 Toast 检测 App 内部拦截 Toast 调用,记录到全局变量
  • 彻底的 UI 清理接调用 Keyboard.dismiss()TextInput.State.blurTextInput()

混合架构:Detox + CDP 双通道

三层能力分工

层级
职责
典型操作
Detox 层
UI 断言、元素交互、截图
`element().tap()`、`waitFor()`、`takeScreenshot()`
CDP 层
状态注入、内部操作、同步信号
`Runtime.evaluate()`、`consoleAPICalled` 事件监听
Inject 层
暴露 API,桥接 App 内部逻辑
`window.login()`、`window.navigate()`

这种架构的优势在于各取所长

  • Detox 的自动同步机制保证了测试稳定性
  • CDP 的运行时访问能力突破了 UI 层的限制
  • Inject 模块将复杂逻辑封装在 App 端,测试代码保持简洁

核心机制一:CDP 同步信号

问题根源:Hermes 的 awaitPromise 未实现

CDP 协议的 Runtime.evaluate 方法有 awaitPromise 参数,根据官方文档,设为 true 时应该等待 Promise resolve 后再返回:

// 期望的 CDP 行为
await client.Runtime.evaluate({
  expression: `window.navigate('/some/path')`,  // 返回 Promise
  awaitPromise: true,  // 等待 Promise
});

然而实际测试发现,无论表达式是否返回 Promise,Hermes 都会立即返回结果。

源码分析过翻阅 Hermes 的 CDP 实现源码,我发现 awaitPromise 参数虽然被正确解析,但在实际处理逻辑中取

文件
作用
awaitPromise
MessageTypes.cpp
解析 JSON 请求
✅ 被解析
RuntimeDomainAgent.cpp
实际处理逻辑
从未读取

RuntimeDomainAgent::evaluate() 函数中,代码直接调用 runtime.evaluateJavaScript() 同步执行,然后立即返回结果,完全没有检查 awaitPromise 参数。这不是”不可靠”,而是根本没有实现

详细的源码分析见 React Native + Detox:基于 CDP 的信号同步机制

解决方案:Console 事件同步

既然 awaitPromise 未实现,我需要在应用层构建一套握手协议。在 CDP 中,Runtime.consoleAPICalled 事件是最可靠的通信载体——只要 App 调用 console.log,调试器一定能收到,因为这是 CDP 协议的核心功能:

关键设计

App 端 inject/helpers.ts):

export const signalDone = (opId: string) => console.log(`[E2E:DONE:${opId}]`);
export const signalError = (opId: string, error: unknown) =>
  console.error(`[E2E:ERROR:${opId}:${error}]`);

Detox 端 cdp/client.ts):

export async function waitForSignal(opId: string, timeout = 60000): Promise<void> {
  const cdpClient = await getCdpClient();

  return new Promise((resolve, reject) => {
    const unsubscribe = cdpClient['Runtime.consoleAPICalled']((params) => {
      const message = params.args?.[0]?.value;
      if (message === `[E2E:DONE:${opId}]`) {
        cleanup();
        resolve();
      } else if (message?.startsWith(`[E2E:ERROR:${opId}:`)) {
        cleanup();
        reject(new Error(/* ... */));
      }
    });

    const timeoutId = setTimeout(() => {
      cleanup();
      reject(new Error(`Signal timeout for ${opId}`));
    }, timeout);

    function cleanup() {
      clearTimeout(timeoutId);
      unsubscribe();
    }
  });
}

使用模式

// actions/navigate.ts
export async function navigate(path: string, params = {}) {
  const client = await getCdpClient();
  const opId = generateOperationId();

  // 1. 先注册监听,避免信号丢失
  const signalPromise = waitForSignal(opId);

  // 2. 触发操作(Fire and Forget)
  await client.Runtime.evaluate({
    expression: `navigate('${path}', ${JSON.stringify(params)}, '${opId}')`,
    awaitPromise: false,  // 不依赖 Hermes 的 Promise 等待
  });

  // 3. 等待 App 端通过 console 发回完成信号
  await signalPromise;
}

优势

  • 绕过未实现的 awaitPromise 依赖 Hermes 的半成品实现
  • 可靠性高 consoleAPICalled` 是 CDP 核心事件,用于调试器 Console 面板
  • 错误传递持通过 [E2E:ERROR:opId:message] 信号传递错误信息
  • 时序可控注册监听再触发操作,避免 Race Condition

核心机制二:CDP 客户端设计

旧代码的问题

原有实现存在严重的并发和健康检查缺陷:

// 旧代码 - 存在并发问题
let client: CDP.Client | null = null;
let waiting = false;

export const getCdpClient = async () => {
  if (client) return client;

  if (waiting) {
    // 问题:setInterval 轮询等待,如果初始化失败会永远卡死
    return new Promise<CDP.Client>((resolve) => {
      const interval = setInterval(() => {
        if (client) {
          clearInterval(interval);
          resolve(client);
        }
      }, 100);
    });
  }

  waiting = true;
  // ... 初始化逻辑
};

核心问题

问题
触发条件
旧代码行为
影响程度
setInterval 永不解析
初始化失败时
后续调用永远卡死
严重
无连接健康检查
僵死连接
无法检测,调用卡住
中等
无断开重连
Metro 重启、网络抖动
返回旧 client,WebSocket 失效
中等
无重试机制
偶发网络问题
测试失败
轻微
waiting 标志不重置
初始化失败后 waiting=true
后续无法重试
严重

新方案:Promise 共享 + 健康检查 + 自动重连

// cdp/client.ts
let client: CDP.Client | null = null;
let initPromise: Promise<CDP.Client> | null = null;

export const getCdpClient = async (): Promise<CDP.Client> => {
  // 1. 检查现有连接 + 健康检查
  if (client && (await isClientConnected(client))) {
    return client;
  }

  // 2. 并发锁:复用正在进行的连接尝试
  if (initPromise) return initPromise;

  // 3. 发起新连接
  initPromise = (async () => {
    try {
      const newClient = await createCdpConnection();
      client = newClient;
      return newClient;
    } finally {
      initPromise = null;  // 无论成功失败都重置
    }
  })();

  return initPromise;
};

关键改进

  1. Promise 共享模式
// 并发调用时,多个测试共享同一个连接 Promise
const [client1, client2] = await Promise.all([
  getCdpClient(),  // 发起连接
  getCdpClient(),  // 复用同一个 initPromise
]);
  1. 健康检查秒超时):
async function isClientConnected(cdpClient: CDP.Client): Promise<boolean> {
  try {
    const healthCheck = cdpClient.Runtime.evaluate({ expression: '1', returnByValue: true });
    const timeout = new Promise<never>((_, reject) =>
      setTimeout(() => reject(new Error('CDP Health Check Timeout')), 2000),
    );

    await Promise.race([healthCheck, timeout]);
    return true;
  } catch {
    return false;
  }
}
  1. 断开监听
newClient.on('disconnect', () => {
  // 防止旧连接断开误重置新连接
  if (client === newClient) {
    client = null;
  }
});
  1. 重试机制
async function createCdpConnection(retries = 3): Promise<CDP.Client> {
  for (let attempt = 1; attempt <= retries; attempt++) {
    try {
      return await CDP({ /* ... */ });
    } catch (err) {
      if (attempt === retries) throw err;
      await new Promise(r => setTimeout(r, 1000));  // 重试间隔 1 秒
    }
  }
}

核心机制三:Console 日志监听

为什么用轮询而非 CDP 事件?

虽然同步信号(login/navigate)成功使用了 consoleAPICalled 事件,但 Console 日志监听采用轮询 + 预置拦截器。原因在于需求本质不同:

同步信号(可控时序)

T1: waitForSignal() 注册监听 ← 监听器已就绪
T2: Runtime.evaluate() 触发操作
T3: App 执行异步逻辑...
T4: signalDone(opId) 发出信号
T5: 监听器收到,resolve()

信号在 T4 才产生,监听器在 T1 已就绪。时序可控,不需要历史数据

Console 日志(不可控时序)

T0: App 开始启动
T1: setupConsoleInterceptor() 注入
T2-T4: 日志产生(启动日志等)
T5: Detox 测试开始
T6: CDP 连接建立 ← 这时才能用 consoleAPICalled!

T2-T4 的日志在 CDP 连接之前就产生,用事件监听会永久丢失

维度
同步信号
Console 日志
产生时机
可控(操作触发后)
不可控(任何时刻)
需要历史
不需要
需要启动日志
丢失后果
测试卡死
调试信息不完整
设计策略
事件驱动(零延迟)
存储+轮询(100ms 延迟)

实现方案

App 端 inject/interceptors.ts):

export function setupConsoleInterceptor() {
  // 始终初始化存储数组,确保后续代码可访问
  window._consoleLogs = [];
  window._consoleLogsInitialized = true;

  // 如果未开启日志收集,仅初始化不拦截
  if (process.env.EXPO_PUBLIC_E2E_CONSOLE_LOG !== 'true') {
    console.log('Console interceptor initialized (logging disabled)');
    return;
  }

  // 拦截 5 种类型
  for (const type of ['log', 'warn', 'error', 'info', 'debug'] as const) {
    const orig = console[type];
    console[type] = (...args) => {
      // 安全序列化:对象使用 JSON.stringify,异常时 fallback 到 String()
      const serializedArgs = args.map(arg => {
        try {
          return typeof arg === 'object' ? JSON.stringify(arg) : String(arg);
        } catch {
          return String(arg);
        }
      });

      window._consoleLogs.push({ type, args: serializedArgs, timestamp: Date.now() });
      orig.apply(console, args);
    };
  }
  console.log('Console interceptor initialized (logging enabled)');
}

Detox 端 listeners/console.ts):

export async function listenToConsoleLogs(listener): Promise<() => void> {
  const client = await getCdpClient();

  const intervalId = setInterval(async () => {
    try {
      const resp = await client.Runtime.evaluate({
        expression: `JSON.stringify(window._consoleLogs.splice(0))`,
        returnByValue: true,  // 必须添加!
      });

      const logs = JSON.parse(resp.result.value as string);
      for (const logEntry of logs) {
        listener(logEntry);
      }
    } catch {
      // 静默处理错误,避免轮询中断
    }
  }, 100);

  return () => clearInterval(intervalId);  // 返回停止函数
}

日志收集器 listeners/console-collector.ts):

按测试收集日志,用于生成报告:

class ConsoleLogCollector {
  private logs: TimestampedLogEntry[] = [];
  private testBoundaries: Map<string, { start: number; end?: number }> = new Map();

  markStart(testId: string) {
    this.testBoundaries.set(testId, { start: this.logs.length });
  }

  markEnd(testId: string) {
    const boundary = this.testBoundaries.get(testId);
    if (boundary) boundary.end = this.logs.length;
  }

  getLogs(testId: string): CategorizedLogs {
    const boundary = this.testBoundaries.get(testId);
    if (!boundary) return { e2eLogs: [], appLogs: [] };

    const testLogs = this.logs.slice(boundary.start, boundary.end);

    return {
      e2eLogs: testLogs.filter(isE2ELog),
      appLogs: testLogs.filter(log => !isE2ELog(log)),
    };
  }
}

P00 页面巡检设计

什么是 P00?

P00(Priority 0)是最高优先级的测试套件,设计目标是快速发现阻塞性问题不验证业务逻辑的正确性,而是确保每个页面都能正常打开、渲染完成、没有报错。

类比来说:P00 就像医院的”体检套餐”——不治病,但能快速发现异常指标。

设计理念

核心原则康检查为红线,视觉 diff 为监控

在早期设计中,曾尝试将视觉对比作为测试通过的标准。但很快发现这行不通:

  1. 动态内容间戳、随机数、用户数据导致截图每次都不同
  2. 网络延迟 PI 响应时间不稳定,内容加载顺序可能变化
  3. 设备差异同模拟器的渲染可能有细微差异

视觉对比只能反映”变化”,不能判断”对错”。因此最终设计为:

  • 健康检查决定 PASS/FAIL 测红屏、ErrorBoundary 等致命错误
  • 视觉监控仅作辅助录 UI 变化,但不阻塞 CI

两层检查模型

状态定义

状态
含义
CI 行为
PASS
测试通过
✅ 通过
WARN
视觉差异超阈值
✅ 通过,记录
FAIL
健康检查未通过
❌ 阻塞
SKIP
配置跳过
✅ 通过

健康检查实现

检测项

  • 错误文本发 FAIL):RN 红屏、ErrorBoundary 等
  • Toast 记录):API 错误、成功提示等

关键代码 suites/p00/health-checker.ts):

export async function performHealthCheck(config: ScreenConfig): Promise<HealthCheckResult> {
  const errors: DetectedError[] = [];

  // 1. 检查错误文本
  for (const errorText of ERROR_TEXTS) {
    if (shouldTolerate(errorText, config.healthCheck?.tolerateErrors)) continue;
    if (await elementExists(errorText, config.healthCheck?.detectTimeout)) {
      errors.push({ type: 'TEXT', message: errorText });
    }
  }

  // 2. 检测 Toast(仅记录,不阻塞测试)
  const toastMessages = await detectToasts();

  return {
    passed: errors.length === 0,  // Toast 不影响 passed
    errors,
    toastMessages: toastMessages.length > 0 ? toastMessages : undefined,
    duration: Date.now() - startTime,
  };
}

Toast 多条检测

一个页面可能有多个 API 错误,需要显示所有 Toast:

async function detectToasts(): Promise<string[]> {
  const client = await getCdpClient();
  const result = await client.Runtime.evaluate({
    expression: `
      (() => {
        const messages = [];

        // 添加当前显示的 Toast
        if (window._currentToastMessage) {
          messages.push(window._currentToastMessage);
        }

        // 添加所有历史 Toast(去重)
        const history = window._toastHistory || [];
        history.forEach(t => {
          if (!messages.includes(t.message)) {
            messages.push(t.message);
          }
        });

        return messages;
      })()
    `,
    returnByValue: true,
  });

  return (result.result.value ?? []) as string[];
}

原因

  • 导航时已清空历史(navigation.ts),所以 _toastHistory 只包含当前页面的 Toast
  • 通过 CDP 读取 window._currentToastMessagewindow._toastHistory 获取所有 Toast

视觉监控实现

流程

1. prepareForScreenshot()
   ├── hideKeyboard()       # 隐藏键盘
   ├── clearFocus()         # 清除焦点
   ├── clearToast()         # 清除 Toast
   └── wait(1500ms)         # 等待渲染稳定

2. device.takeScreenshot()

3. 保存策略
   ├── 更新模式(UPDATE_BASELINE=true)
   │   └── 保存到 baseline/

   └── 对比模式(UPDATE_BASELINE=false)
       ├── 无基准图
       │   ├── 保存到 baseline/
       │   └── 保存到 current/

       └── 有基准图
           ├── odiff 对比
           └── 始终保存到 current/(无论是否有差异)

截图保存规则

模式
场景
保存到 baseline?
保存到 current?
保存到 diff?
更新模式
所有页面



对比模式
无基准图



对比模式
有基准图 + 无差异



对比模式
有基准图 + 有差异(≤阈值)



对比模式
有基准图 + 有差异(>阈值)



关键特性

  • ✅ 每次运行前清空 current/diff/ 目录(按平台)
  • current/ 目录始终包含本次运行的所有页面截图
  • baseline/ 目录由 Git 管理,只在更新模式或新页面时写入
  • diff/ 目录只在差异超阈值时生成

阈值策略

类型
阈值
适用场景
static
1%
纯静态页面
dynamic
5%
有动态数据(默认)
risky
15%
高动态内容(地图等)

差异类型处理

差异类型
说明
处理方式
pixel-diff
像素差异
按阈值判断是否超过
layout-diff
布局差异(尺寸不同)
视为 100% 差异,必定超阈值

产物文件命名

e2e/artifacts/p00/
├── baseline/                          # 基准图(Git 管理)
│   ├── ios/
│   │   ├── calendar-view.png          # 文件名不含平台前缀
│   │   └── customer-detail.png
│   └── android/
│       ├── calendar-view.png
│       └── customer-detail.png

├── current/                           # 当前截图(每次运行生成)
│   ├── ios/
│   │   ├── calendar-view.png
│   │   └── customer-detail.png
│   └── android/
│       ├── calendar-view.png
│       └── customer-detail.png

└── diff/                              # 差异图(超阈值时生成)
    ├── ios/
    │   └── calendar-view.png          # 差异图不加 -diff 后缀
    └── android/
        └── calendar-view.png

说明:
- 平台通过目录区分,文件名统一格式
- 文件名格式:{路径转换}.png(如 /calendar/view → calendar-view.png)
- current/ 和 diff/ 目录被 .gitignore 忽略

页面配置设计

配置分离,渐进式

业务配置与测试逻辑分离,90% 的页面只需要最简配置:

// suites/p00/screens.ts

// 简单页面(大多数情况)
{ path: '/calendar/view' }

// 需要参数
{ path: '/customer/detail', mockParams: { id: 123 } }

// 特殊处理
{
  path: '/map/view',
  category: 'risky',
  visualMonitor: { threshold: 0.2 }
}

// 跳过测试
{ path: '/some/broken/page', skip: true, skipReason: '已知问题' }

ScreenConfig 类型定义

interface ScreenConfig {
  / 路由路径,必须以 '/' 开头 */
  path: `/${string}`;

  /否跳过 */
  skip?: boolean;
  skipReason?: string | string[];

  /数 */
  mockParams?: Record<string, unknown>;

  /置 */
  healthCheck?: {
    loadWait?: number;           // 页面加载等待时间(毫秒)
    detectTimeout?: number;      // 单次元素检测超时(毫秒)
    tolerateErrors?: string[];   // 容忍的错误关键词
  };

  /*/
  visualMonitor?: {
    skip?: boolean;              // 跳过视觉监控
    threshold?: number;          // 自定义 diff 阈值
    waitBeforeCapture?: number;  // 截图前等待时间
  };

  / diff 阈值 */
  category?: 'static' | 'dynamic' | 'risky';
}

Inject 模块设计

初始化顺序

// inject/index.ts
console.log('Initializing inject modules...');

setupEnvironment();      // 1. 环境设置
setupUIHelpers();        // 2. UI 辅助
setupConsoleInterceptor(); // 3. Console 拦截
setupToastInterceptor(); // 4. Toast 拦截
setupNavigation();       // 5. 导航功能
setupAuth();             // 6. 登录功能

console.log('All inject modules initialized');

Toast 拦截器详解

问题背景

在 E2E 测试中,Toast 提示是重要的反馈信息来源。但项目中有两种 Toast 使用方式:

// 方式1:封装的 toast() 函数
import { toast } from '@/utils/toast';
toast('操作成功');  // ✅ 会设置 window._currentToastMessage

// 方式2:直接使用 @ant-design/react-native
import { Toast } from '@ant-design/react-native';
Toast.fail('请求失败');  // ❌ 不会设置 window._currentToastMessage

特别是 initHTTP.ts 中的 API 错误处理使用的是方式 2,导致 E2E 测试无法检测到这些错误提示。

解决方案

Monkey Patch@ant-design/react-native` 的所有 Toast 方法:

export function setupToastInterceptor() {
  const Toast = require('@ant-design/react-native').Toast;

  // 拦截 5 种方法
  for (const method of ['info', 'success', 'fail', 'loading', 'offline']) {
    const original = Toast[method];
    Toast[method] = (content, duration, onClose) => {
      // 记录到全局
      window._currentToastMessage = content;
      window._toastHistory = window._toastHistory || [];
      window._toastHistory.push({ message: content, timestamp: Date.now() });
      if (window._toastHistory.length > 20) window._toastHistory.shift();

      // 调用原方法
      const key = original.call(Toast, content, duration, () => {
        // Toast 自动清除时同步清空
        if (window._currentToastMessage === content) {
          window._currentToastMessage = undefined;
        }
        onClose?.();
      });

      return key;
    };
  }

  // 拦截清除方法
  const originalRemoveAll = Toast.removeAll;
  Toast.removeAll = () => {
    window._currentToastMessage = undefined;
    originalRemoveAll.call(Toast);
  };
}

效果 统一捕获所有 Toast,包括 API 错误提示

导航功能详解

设计背景

项目使用 React Navigation,路由结构如下:

RootStack
├── /home (Drawer Navigator)  ←── Drawer 页面
│   ├── /calendar/view
│   ├── /overview
│   └── /messages

├── /agreement/detail         ←── Stack 页面
├── /customer/detail
└── ...

挑战 rawer 页面和 Stack 页面的导航方式不同:

页面类型
导航方式
示例
Drawer 页面
需要嵌套导航:先到 /home,再到子页面
/calendar/view
Stack 页面
直接导航到目标页面
/customer/detail

如果对 Drawer 页面使用直接导航,会导致路由栈异常。

实现要点

// window.navigate 核心逻辑
window.navigate = async (path, params = {}, opId?) => {
  // 导航前清空 Toast 历史,确保检测的是当前页面的 Toast
  window._toastHistory = [];

  if (isDrawerRoute(path)) {
    // Drawer 页面:嵌套导航 → /home → { screen: path }
    globalNavigation.current?.dispatch(
      CommonActions.navigate(PATH_HOME.key, { screen: path, params })
    );
  } else {
    // Stack 页面:直接导航
    globalNavigation.current?.dispatch(new RoutePath(path).navigate(params));
  }

  await waitForRoute(path);  // 事件监听等待导航完成
  if (opId) signalDone(opId);
};

waitForRoute 实现用 globalNavigation.addListener('state') 监听导航状态变化,确保导航完成后再返回。


业务代码侵入点

E2E 工具集

为避免 hack 代码散落,已将所有 E2E 相关工具集中到 src/utils/e2e/

文件
工具
用途
helpers.ts
isE2EMode()
判断是否为 E2E 环境
hooks.ts
useE2EAutoClose()
自动关闭弹窗/提示
hooks.ts
useE2EAutoSkip()
自动跳过引导流程
components.tsx

E2E 环境下隐藏子组件
components.tsx

根据环境条件渲染

设计理念

  • 集中管理有 E2E hack 都在 src/utils/e2e/ 目录下
  • 快速追踪索 utils/e2e 即可找到所有使用点
  • 易于维护来移除时可批量处理

当前使用清单 个文件):

文件
使用的工具
用途
App.tsx
E2EConditional
禁用 InspectorWrapper
Switch2Insights.tsx
isE2EMode, useE2EAutoClose
自动关闭 Insights 提示
OnboardingPopover.tsx
isE2EMode
跳过 MoePay 引导
ReviewBoosterRequest.tsx
isE2EMode
跳过 Review Booster 引导
SetInDesktopGuide.tsx
isE2EMode
隐藏桌面引导弹窗
PayrollSetting.tsx
E2EHidden, useE2EAutoSkip
跳过 Payroll 引导

其他必要修改

文件
修改内容
index.js
E2E 模式下加载注入代码
src/utils/alert.ts
确认弹窗自动绕过

Inject 代码加载

// index.js
if (process.env.EXPO_PUBLIC_IS_E2E === 'true') {
  import('./e2e/lib/inject/index').then(init);
} else {
  init();
}

确认弹窗自动绕过

// src/utils/alert.ts - dangerConfirmAsync / primaryConfirmAsync
if (process.env.EXPO_PUBLIC_IS_E2E === 'true') {
  return Promise.resolve(buttonText); // 自动确认
}

启动流程

两阶段启动策略

问题背景接带 URL 启动会导致卡在 Development servers 页面

解决方案

阶段 1: device.launchApp({ newInstance: true })

        等待 3s(Expo Dev Client 初始化)

阶段 2: device.openURL({ url: deepLinkUrl })

        等待 5s(JS Bundle 下载和解析)

Deep Link 格式

exp+moego-business-2://expo-development-client/?url=http%3A%2F%2Flocalhost%3A8081

Jest 启动配置

// jest.setup.ts
beforeAll(async () => {
  // 1. 优化 Android 环境(禁用系统动画)
  if (device.getPlatform() === 'android') {
    await optimizeAndroidEnvironment();
  }

  // 2. 两阶段启动 App
  await launchApp();

  // 3. 设置设备位置(默认旧金山唐人街)
  await setLocation();

  // 4. 启用 Console 日志监听(连接 CDP)
  stopListeningConsoleLogs = await listenToConsoleLogs((entry) => {
    logFn(`[App] ${entry.args.join(' ')}`);
    consoleCollector.collectLog(entry);  // 收集到收集器
  });

  // 5. 等待 inject.ts 初始化完成(15s 超时)
  await waitForInjectReady();
});

Android 端口配置

问题背景

Android 模拟器与 iOS 模拟器的网络机制不同:

解决方案

框架通过 .detoxrc.js 中的 reversePorts 配置自动处理:

// .detoxrc.js
const METRO_PORT = Number(process.env.EXPO_PUBLIC_METRO_PORT) || 8081;

module.exports = {
  apps: {
    'android.debug': {
      reversePorts: [METRO_PORT],  // Detox 自动执行 adb reverse
    },
  },
};

端口配置流转

EXPO_PUBLIC_METRO_PORT

        ├──► .detoxrc.js → reversePorts → adb reverse
        ├──► jest.setup.ts → CONFIG.METRO_PORT → Deep Link URL
        └──► cdp/client.ts → CDP({ port })

CI 并行测试过环境变量配置不同端口

# Worker 1
EXPO_PUBLIC_METRO_PORT=8081 pnpm start:e2e &
EXPO_PUBLIC_METRO_PORT=8081 pnpm test:run-e2e-android

# Worker 2
EXPO_PUBLIC_METRO_PORT=8082 pnpm start:e2e &
EXPO_PUBLIC_METRO_PORT=8082 pnpm test:run-e2e-android

关键经验与教训

在整个 E2E 体系建设过程中,踩过不少坑。这里总结几条可能对其他项目有参考价值的经验。

1. 同步机制的选择

经验

  • 简单同步操作(clearToast()hideKeyboard())可以使用 awaitPromise: true
  • 复杂异步操作(login()navigate())必须使用 Console 事件同步
  • 持续监听(Console 日志)需要轮询 + 预置拦截器,而非 CDP 事件

原因

  • Hermes 的 awaitPromise 实现与标准 CDP 有差异
  • consoleAPICalled 事件可靠,但无法捕获 CDP 连接前的日志

2. CDP 客户端的健壮性

经验

  • 必须有健康检查机制(2 秒超时)
  • 必须有断开监听和自动重连
  • 必须有并发控制(Promise 共享模式)

踩坑

  • 早期版本使用 setInterval 轮询,初始化失败后永远卡死
  • 没有健康检查,僵死连接导致测试卡住
  • 没有 finally 重置 initPromise,失败后无法重试

3. Toast 拦截的必要性

经验

  • 必须拦截所有 Toast 方法(包括第三方库直接调用)
  • 必须支持多条 Toast 检测(一个页面可能有多个 API 错误)
  • 导航时清空历史,确保检测的是当前页面的 Toast

效果

  • 成功捕获了 initHTTP.ts 中的 API 错误提示
  • 提高了测试的可观测性

4. 视觉监控的定位

经验

  • 视觉 diff 不能作为准确的检测手段,只能反映变化
  • 健康检查决定 PASS/FAIL,视觉监控仅作为监控手段
  • 阈值设置需要根据页面类型调整(static/dynamic/risky)

原因

  • 动态内容(时间、随机数)导致截图不稳定
  • 网络请求延迟导致内容加载不同步
  • 不同设备的渲染差异

5. 业务代码侵入的最小化

经验

  • 将所有 E2E hack 集中到 src/utils/e2e/ 目录
  • 使用环境变量 EXPO_PUBLIC_IS_E2E 隔离 E2E 逻辑
  • 确认弹窗自动绕过放在 alert.ts 中,一处修改全局生效

效果

  • 快速追踪:搜索 utils/e2e 即可找到所有使用点
  • 易于维护:未来移除时可批量处理
  • 对生产环境零影响

快速排查清单

CDP 连接失败

症状 Error: Cannot connect to CDPtargets: []

排查步骤

  1. 检查 Metro 是否启动: pnpm start:e2e
  2. 检查端口是否被占用: lsof -i :8081
  3. Android 特有:检查端口转发 adb reverse --list
  4. 重启模拟器

注入代码未加载

症状 Error: window.login is not defined

排查步骤

  1. 检查 index.js 中的条件加载逻辑
  2. 查看 Metro 日志确认 inject/index.ts 是否加载
  3. 增加 waitForInjectReady() 的超时时间

截图对比失败

症状大量页面超阈值

排查步骤

  1. 检查模拟器型号是否一致(iPhone 16 / Pixel 7)
  2. 检查系统设置(字体大小、深色模式)
  3. 重新生成基准图: pnpm test:update-baseline-ios

测试超时

症状 Timeout of 120000ms exceeded

排查步骤

  1. 检查网络请求是否正常
  2. 增加超时时间: 修改 .detoxrc.js 中的 testTimeout
  3. 添加更明确的等待条件

落地效果与收益

测试覆盖

指标
数据
页面覆盖
300+ 页面(40 个业务模块)
执行时间
iOS 全量约 25 分钟,Android 全量约 30 分钟
登录耗时
从 10 秒优化到 1 秒(绕过 UI 表单)
导航效率
从逐级菜单点击优化到直接跳转

发现的问题类型

自上线以来,P00 页面巡检已帮助发现多类问题:

  1. 页面崩溃些参数组合导致的红屏错误
  2. API 异常口变更导致的 Toast 错误提示
  3. UI 回归式修改意外影响其他页面
  4. 加载问题据未加载完成时的空白状态

团队收益

  • 回归测试效率提升人工验证 2-3 小时缩短到自动化 30 分钟
  • 问题发现前移开发阶段即可发现潜在问题,减少线上 Bug
  • 变更信心增强构或升级依赖时,有自动化测试保驾护航
  • 新人上手加速过 E2E 测试快速了解业务流程

下一步规划

短期优化

  1. 性能优化

    • 探索并行测试
    • 优化截图对比速度(缓存策略)
  2. 测试覆盖

    • 增加冒烟测试(smoke)套件
    • 增加核心流程测试(core)套件
  3. 可观测性

    • 增强可视化报告(集成差异图预览)
    • 增加测试执行录屏功能

长期规划

  1. CI/CD 集成

    • 增加自动基准图审核流程
    • 增加测试失败自动重试机制
  2. 测试数据管理

    • 增加测试数据自动生成能力
    • 增加测试环境自动重置能力
技术实践 APP开发 React Native E2E测试