React Native + Detox:架构设计与 P00 页面巡检实践
AI 速览
这篇文章是系列综述,讲述了如何从零开始为一个拥有 300+ 页面的 React Native 应用构建完整的 E2E 自动化测试体系。文章的亮点在于提出了 Detox + CDP 混合架构 etox 擅长 UI 断言但无法操作 App 内部状态,CDP 可以执行任意 JS 代码但缺乏 UI 测试能力,两者结合实现了”灰盒测试”的最佳实践。另一个核心设计是 双层防护体系决定测试是否通过,视觉监控只作为警示而不阻塞 CI,避免了截图对比的高误报率问题。最终实现了 300+ 页面的自动化巡检,单次执行约 30 分钟,登录操作从 10 秒优化到 3 秒。
系列文章导航
- 架构设计与 P00 页面巡检实践 (本文)← 推荐先读
- iOS 模拟器环境配置
- Android AOSP 模拟器配置
- Android 模拟器运行测试排查
- 基于 CDP 的信号同步机制
- Bridge 层代码注入实践
- 页面巡检的健康检查与视觉监控
术语表
| 术语 | 全称 | 说明 |
| 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+ 页面。随着业务快速迭代,几个问题逐渐凸显:
- 回归测试成本高次发版前需要人工验证核心流程,耗时且容易遗漏
- 隐性 Bug 频发个模块的修改可能影响其他页面,缺乏自动化手段发现
- 视觉问题难追踪 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 测试需求,业界有多个成熟方案。选型时考虑三个核心因素:
- 录制工具否快速生成测试用例?
- 开发语言否支持 JS/TS?(前端团队更熟悉)
- 用例语法否简洁易读?(类似 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;
};关键改进
- Promise 共享模式
// 并发调用时,多个测试共享同一个连接 Promise
const [client1, client2] = await Promise.all([
getCdpClient(), // 发起连接
getCdpClient(), // 复用同一个 initPromise
]);- 健康检查秒超时):
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;
}
}- 断开监听
newClient.on('disconnect', () => {
// 防止旧连接断开误重置新连接
if (client === newClient) {
client = null;
}
});- 重试机制
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 为监控
在早期设计中,曾尝试将视觉对比作为测试通过的标准。但很快发现这行不通:
- 动态内容间戳、随机数、用户数据导致截图每次都不同
- 网络延迟 PI 响应时间不稳定,内容加载顺序可能变化
- 设备差异同模拟器的渲染可能有细微差异
视觉对比只能反映”变化”,不能判断”对错”。因此最终设计为:
- 健康检查决定 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._currentToastMessage和window._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%3A8081Jest 启动配置
// 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 CDP 或 targets: []
排查步骤
- 检查 Metro 是否启动:
pnpm start:e2e - 检查端口是否被占用:
lsof -i :8081 - Android 特有:检查端口转发
adb reverse --list - 重启模拟器
注入代码未加载
症状 Error: window.login is not defined
排查步骤
- 检查
index.js中的条件加载逻辑 - 查看 Metro 日志确认
inject/index.ts是否加载 - 增加
waitForInjectReady()的超时时间
截图对比失败
症状大量页面超阈值
排查步骤
- 检查模拟器型号是否一致(iPhone 16 / Pixel 7)
- 检查系统设置(字体大小、深色模式)
- 重新生成基准图:
pnpm test:update-baseline-ios
测试超时
症状 Timeout of 120000ms exceeded
排查步骤
- 检查网络请求是否正常
- 增加超时时间: 修改
.detoxrc.js中的testTimeout - 添加更明确的等待条件
落地效果与收益
测试覆盖
| 指标 | 数据 |
| 页面覆盖 | 300+ 页面(40 个业务模块) |
| 执行时间 | iOS 全量约 25 分钟,Android 全量约 30 分钟 |
| 登录耗时 | 从 10 秒优化到 1 秒(绕过 UI 表单) |
| 导航效率 | 从逐级菜单点击优化到直接跳转 |
发现的问题类型
自上线以来,P00 页面巡检已帮助发现多类问题:
- 页面崩溃些参数组合导致的红屏错误
- API 异常口变更导致的 Toast 错误提示
- UI 回归式修改意外影响其他页面
- 加载问题据未加载完成时的空白状态
团队收益
- 回归测试效率提升人工验证 2-3 小时缩短到自动化 30 分钟
- 问题发现前移开发阶段即可发现潜在问题,减少线上 Bug
- 变更信心增强构或升级依赖时,有自动化测试保驾护航
- 新人上手加速过 E2E 测试快速了解业务流程
下一步规划
短期优化
性能优化
- 探索并行测试
- 优化截图对比速度(缓存策略)
测试覆盖
- 增加冒烟测试(smoke)套件
- 增加核心流程测试(core)套件
可观测性
- 增强可视化报告(集成差异图预览)
- 增加测试执行录屏功能
长期规划
CI/CD 集成
- 增加自动基准图审核流程
- 增加测试失败自动重试机制
测试数据管理
- 增加测试数据自动生成能力
- 增加测试环境自动重置能力