Home
Perfecto的头像

Perfecto

React Native + Detox:页面巡检的健康检查与视觉监控

AI 速览

这篇文章探讨了一个看似简单实则复杂的问题:如何自动检测 300+ 页面是否正常渲染?最初的想法是截图对比,但实践中发现误报率极高——动态数据变化、地图组件随机性、时间戳更新都会触发像素差异。文章提出的解决方案是双层防护体系一层”健康检查”专注于检测真正的错误(红屏、ErrorBoundary、Toast 报错),失败则阻塞 CI;第二层”视觉监控”使用 odiff 进行像素对比,但只作为警示信息,不影响测试结果。这种设计背后的核心洞察是:不等于功能错误,视觉正常也不等于没有错误离,各司其职


系列文章导航


背景

在 基于 CDP 的信号同步机制 和 Bridge 层代码注入实践 中,我们已经解决了如何与 App 通信*如何测试代码心问现在我们拥有了:

  • CDP 通信层通过 Chrome DevTools Protocol 与 Hermes 引擎通信
  • 信号同步机制基于 console 事件的可靠异步等待
  • 注入代码在 App 启动时初始化的全局函数和拦截器

有了这些基础设施,我们终于可以构建真正的 E2E 测试场景 —— P00 页面巡检


什么是 P00 页面巡检?

P00 (Page-00) 是我们 E2E 测试体系的第一道防线的目标非常直接:

自动访问 App 中所有页面,确保每个页面都能正常渲染,没有崩溃、红屏或错误。

在我们的项目中,有超过 300 个页面:

  • 日历管理 (Calendar)
  • 客户档案 (Customer)
  • 预约详情 (Appointment)
  • 财务报表 (Finance)
  • 员工管理 (Staff)
  • 系统设置 (Settings)

如果靠手动测试,每个页面点一遍需要 数小时 P00 可以在 内查,并生成详细报告。


为什么需要”双层”防护?

最初的想法很简单: 访问每个页面,截图,对比。但在实践中我发现:

问题 1: 截图对比的误报率高

场景 1: 动态数据变化
- 基准图: "今天预约 5 个"
- 当前图: "今天预约 3 个"
- 结果: odiff 报告差异 ❌ 但这是正常的数据变化!

场景 2: 地图组件的随机性
- Google Maps 加载时地图瓦片可能略有不同
- 结果: odiff 报告大量像素差异 ❌ 但页面功能正常!

场景 3: 时间戳和动画
- "2 分钟前" vs "3 分钟前"
- 进度条动画的不同帧
- 结果: 像素差异 ❌ 但不是真正的 bug!

关键洞察 ≠ 功能错误误报淹没真正的问题。

问题 2: 单纯截图无法检测隐藏错误

场景 1: Toast 错误一闪而过
- App 发起了一个 API 请求,返回 500 错误
- Toast 显示 "Network Error" 后自动消失
- 截图时 Toast 已经消失,看起来一切正常 ❌

场景 2: ErrorBoundary 捕获后的降级
- 页面某个组件崩溃,被 ErrorBoundary 捕获
- UI 显示一个友好的占位符 "Something went wrong"
- 截图看起来"正常",但实际上功能已损坏 ❌

关键洞察 ≠ 没有错误层的检测机制。


解决方案: 双层防护体系

我设计了一套 “健康检查为红线,视觉监控为警示”分层策略:

核心设计理念

  1. 健康检查严格的功能性检测,发现真正的 bug
  2. 视觉监控宽松的提醒机制,标记 UI 变化
  3. 分离关注点两者互不干扰,各司其职

环境

  • React Native0.74.5
  • Detox20.42.0
  • Hermes Engine(React Native 内置)
  • Metro Bundler(React Native 内置)
  • CDP Clientchrome-remote-interface 0.33.2
  • 图片对比 odiff-bin 3.1.2
  • 测试框架 Jest + Detox

实现 1: 健康检查器 (Health Checker)

设计思路

健康检查的核心是 “主动探测 + 被动监听”

检测类型
实现方式
检测时机
RN 红屏
读取 window._isAppCrashed
页面加载后
ErrorBoundary
读取 window._lastErrorBoundary
页面加载后
Toast 错误
读取 window._toastHistory
页面加载后
特定错误文本
配置项: tolerateErrors
页面加载后

核心代码

// e2e/suites/p00/health-checker.ts
import { evaluateCode } from 'e2e/lib/cdp/actions';

export async function performHealthCheck(
  config: ScreenConfig
): Promise<HealthCheckResult> {
  // 1. 等待页面稳定
  const waitTime = config.healthCheck?.loadWait ?? 1000;
  await new Promise((resolve) => setTimeout(resolve, waitTime));

  // 2. 检测 RN 红屏
  const isAppCrashed = await evaluateCode<boolean>('window._isAppCrashed ?? false');
  if (isAppCrashed) {
    return {
      status: 'FAIL',
      errors: ['App crashed (RN Red Screen)'],
    };
  }

  // 3. 检测 ErrorBoundary
  const errorBoundary = await evaluateCode<string | null>(
    'window._lastErrorBoundary ?? null'
  );
  if (errorBoundary) {
    return {
      status: 'FAIL',
      errors: [`ErrorBoundary: ${errorBoundary}`],
    };
  }

  // 4. 检测 Toast 错误
  const toastMessages = await detectToasts();
  const errorToasts = filterErrorToasts(toastMessages, config.healthCheck?.tolerateErrors);

  if (errorToasts.length > 0) {
    return {
      status: 'FAIL',
      errors: errorToasts.map((t) => `Toast: ${t}`),
      toastMessages: errorToasts,
    };
  }

  // 5. 全部通过
  return {
    status: 'PASS',
    toastMessages: toastMessages.length > 0 ? toastMessages : undefined,
  };
}

Toast 检测的挑战

Toast 消息的生命周期很短 (通常 2-4 秒),截图时可能已经消失。我决定设计一个 Toast 拦截器决:

// e2e/lib/inject/toast.ts (App 端注入)
const originalShowToast = Toast.show;

Toast.show = (content: string, ...args: any[]) => {
  // 记录到历史队列
  if (!window._toastHistory) {
    window._toastHistory = [];
  }
  window._toastHistory.push(content);

  // 同时记录当前 Toast
  window._currentToastMessage = content;

  // 调用原函数
  return originalShowToast(content, ...args);
};

关键点

  • _toastHistory: 保存所有 Toast 消息的历史队列
  • _currentToastMessage: 最新的 Toast 消息
  • Detox 端读取这两个全局变量,即可获取完整的 Toast 记录

实现 2: 视觉监控器 (Visual Monitor)

设计思路

视觉监控分为三个阶段:

  1. 准备阶段清理干扰元素 (键盘、Toast、焦点)
  2. 截图阶段使用 Detox API 截图
  3. 对比阶段使用 odiff 对比像素差异

核心代码

// e2e/suites/p00/visual-monitor.ts
import { device } from 'detox';
import { execSync } from 'child_process';
import * as fs from 'fs';
import * as path from 'path';

export async function performVisualMonitor(
  screenId: string,
  config: ScreenConfig,
  platform: 'ios' | 'android'
): Promise<VisualMonitorResult> {
  // 1. 准备阶段: 清理干扰元素
  await prepareForScreenshot();

  // 2. 截图阶段
  const currentPath = path.join(ARTIFACTS_DIR, 'current', platform, `${screenId}.png`);
  const baselinePath = path.join(ARTIFACTS_DIR, 'baseline', platform, `${screenId}.png`);
  const diffPath = path.join(ARTIFACTS_DIR, 'diff', platform, `${screenId}.png`);

  await device.takeScreenshot(screenId);
  fs.renameSync(
    path.join(ARTIFACTS_DIR, `${screenId}.png`),
    currentPath
  );

  // 3. 更新模式: 直接保存到 baseline
  if (process.env.UPDATE_BASELINE === 'true') {
    fs.copyFileSync(currentPath, baselinePath);
    return { status: 'PASS', message: 'Baseline updated' };
  }

  // 4. 对比模式: 检查是否有基准图
  if (!fs.existsSync(baselinePath)) {
    // 新页面,保存基准图
    fs.copyFileSync(currentPath, baselinePath);
    return { status: 'PASS', message: 'New baseline created' };
  }

  // 5. 对比阶段: 使用 odiff
  const threshold = getThreshold(config.category);
  const diffResult = runOdiff(baselinePath, currentPath, diffPath, threshold);

  if (diffResult.hasDiff && diffResult.diffPercent > threshold) {
    return {
      status: 'WARN',
      diffPercent: diffResult.diffPercent,
      threshold,
      diffImagePath: diffPath,
    };
  }

  return { status: 'PASS' };
}

截图前的清理工作

这是最容易被忽略但非常重要的细节:

// e2e/lib/actions/ui.ts
export async function prepareForScreenshot(): Promise<void> {
  await evaluateCode('window.hideKeyboard?.()');  // 隐藏键盘
  await evaluateCode('window.clearFocus?.()');     // 清除焦点
  await evaluateCode('window.clearToast?.()');     // 清除 Toast

  // 等待 UI 稳定
  await new Promise((resolve) => setTimeout(resolve, 1500));
}

为什么需要这些?

  • 键盘 iOS 键盘高度不固定,会导致截图差异
  • 焦点输入框的光标闪烁会导致像素差异
  • ToastToast 可能在截图时部分显示,导致不稳定

实现 3: 阈值策略 —— 如何判断”差异是否可容忍”?

这是视觉监控最核心的设计决策。我根据页面特性分为三类:

页面类型
阈值
典型场景
说明
static
1%
设置页面、关于页面
纯静态文本,不应有变化
dynamic
5%
列表页面、详情页面
有动态数据 (默认)
risky
15%
地图页面、图表页面
高动态内容,地图瓦片、实时数据

配置示例

// e2e/suites/p00/screens.ts
const screens: ScreenConfig[] = [
  // 纯静态页面
  {
    path: '/account/setting',
    category: 'static',  // 阈值 1%
  },

  // 默认动态页面
  {
    path: '/calendar/view',
    // 阈值 5% (默认)
  },

  // 高风险页面
  {
    path: '/van/location-tracking',
    category: 'risky',  // 阈值 15%
  },
];

odiff 执行细节

function runOdiff(
  baselinePath: string,
  currentPath: string,
  diffPath: string,
  threshold: number
): OdiffResult {
  try {
    // odiff 命令: --threshold 0.01 表示 1%
    const command = `odiff "${baselinePath}" "${currentPath}" "${diffPath}" --threshold ${threshold / 100}`;

    execSync(command, { stdio: 'pipe' });

    // odiff 返回 0 表示无差异或差异 ≤ 阈值
    return { hasDiff: false, diffPercent: 0 };
  } catch (error) {
    // odiff 返回非 0 表示差异 > 阈值
    const output = error.stdout?.toString() || '';
    const match = output.match(/Difference: ([\d.]+)%/);
    const diffPercent = match ? parseFloat(match[1]) : 100;

    return { hasDiff: true, diffPercent };
  }
}

关键点

  • odiff 通过退出码判断: 0 = 通过, 非 0 = 超阈值
  • 从 stdout 解析差异百分比
  • 差异图只在超阈值时生成

问题 1: 为什么 Toast 不阻塞测试?

在健康检查的实现中,我遇到了一个哲学性的问题:

Toast 错误应该让测试失败吗?

场景对比

场景 A: RN 红屏
- 页面完全崩溃,无法使用
- 用户体验: 💀 不可接受
- 测试结果: ❌ FAIL (阻塞 CI)

场景 B: Toast "Network Error"
- API 请求失败,但页面可继续使用
- 用户体验: ⚠️ 降级但可用
- 测试结果: ??? 应该 FAIL 还是 WARN?

最初的设计 (严格模式)

// ❌ 最初设计: Toast 错误直接 FAIL
if (toastMessages.length > 0) {
  return { status: 'FAIL', errors: toastMessages };
}

问题

  • 测试环境 API 可能不稳定 → 大量误报
  • 某些 Toast 是正常的业务提示 (如 “操作成功”)
  • 300 个页面,任何一个 Toast 都会阻塞 CI

改进版本 (容错模式)

// ✅ 改进设计: Toast 记录但不阻塞
const toastMessages = await detectToasts();

// 仅将 Toast 记录到结果中,不改变 status
return {
  status: 'PASS',
  toastMessages: toastMessages.length > 0 ? toastMessages : undefined,
};

优势

  • Toast 被记录到 JSON 报告中,可追溯
  • 不阻塞 CI,避免误报
  • 可通过 tolerateErrors 配置白名单

终极方案 (白名单过滤)

// e2e/suites/p00/health-checker.ts
function filterErrorToasts(
  toasts: string[],
  tolerateErrors?: string[]
): string[] {
  if (!tolerateErrors || tolerateErrors.length === 0) {
    return []; // 默认容忍所有 Toast
  }

  return toasts.filter((toast) => {
    // 检查是否在白名单中
    return !tolerateErrors.some((pattern) => toast.includes(pattern));
  });
}

配置示例

{
  path: '/payment/flow',
  healthCheck: {
    tolerateErrors: ['Success', 'Completed'],  // 白名单
  },
}

设计原则

  • 默认宽松 Toast 不阻塞测试
  • 可配置严格通过白名单启用严格检查
  • 完整记录所有 Toast 都记录到报告

问题 2: 截图保存策略的演进

最初设计 (简单对比)

// ❌ 最初设计
if (UPDATE_BASELINE) {
  // 保存到 baseline/
  fs.copyFileSync(currentPath, baselinePath);
} else {
  // 只在有差异时保存 current
  if (hasDiff) {
    fs.copyFileSync(currentPath, currentPath);
  }
}

问题

  1. 无差异的页面没有 current 截图
  2. Toast WARN 场景无截图留存
  3. 无法审计完整的测试运行

改进版本 (完整审计)

e2e/artifacts/p00/
├── baseline/           # Git 管理,基准图
│   ├── ios/
│   └── android/

├── current/            # 每次运行生成,完整快照
│   ├── ios/
│   └── android/

└── diff/               # 仅在差异超阈值时生成
    ├── ios/
    └── android/

保存规则表

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



对比模式
无基准图



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



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



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



关键特性

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

实现细节

// e2e/suites/p00/index.test.ts
beforeAll(() => {
  // 运行前清空临时目录
  cleanupArtifacts(platform);
});

function cleanupArtifacts(platform: string) {
  const currentDir = path.join(ARTIFACTS_DIR, 'current', platform);
  const diffDir = path.join(ARTIFACTS_DIR, 'diff', platform);

  // 清空但保留目录结构
  if (fs.existsSync(currentDir)) {
    fs.rmSync(currentDir, { recursive: true });
  }
  if (fs.existsSync(diffDir)) {
    fs.rmSync(diffDir, { recursive: true });
  }

  fs.mkdirSync(currentDir, { recursive: true });
  fs.mkdirSync(diffDir, { recursive: true });
}

问题 3: 页面导航的可靠性

P00 需要访问 300+ 页面,其中很多页面需要参数 (如客户详情页需要 customerId)。

挑战

// 错误示范 1: 使用 Detox 原生 API
await element(by.text('Customer')).tap();  // ❌ 可能找不到元素
await element(by.text('John Doe')).tap();  // ❌ 数据不稳定

// 错误示范 2: 模拟点击
await evaluateCode(`
  document.querySelector('[data-testid="customer-list"]').click();
`);  // ❌ React Navigation 可能不触发

解决方案: 直接调用 Navigation API

// e2e/lib/inject/navigation.ts (App 端注入)
import { createNavigationContainerRef } from '@react-navigation/native';

export const globalNavigation = createNavigationContainerRef();

window.navigate = async (path: string, params?: any, opId?: string) => {
  try {
    const route = findRoute(path);  // 从路由表查找

    globalNavigation.current?.navigate(route.screen, params);

    // 等待页面稳定
    await new Promise((resolve) => setTimeout(resolve, 1000));

    if (opId) signalDone(opId);
  } catch (error) {
    if (opId) signalError(opId, error);
  }
};

优势

  • ✅ 绕过 UI 层,直接操作导航栈
  • ✅ 支持任意参数
  • ✅ 与业务代码使用相同的 Navigation API
  • ✅ 通过信号机制同步等待

P00 使用示例

// e2e/suites/p00/index.test.ts
for (const screen of screens) {
  test(`P00/${screen.path}`, async () => {
    // 1. 导航到页面
    await navigate(screen.path, screen.mockParams);

    // 2. 健康检查
    const healthResult = await performHealthCheck(screen);
    if (healthResult.status === 'FAIL') {
      // 记录失败,继续下一个页面
      results.push({ path: screen.path, health: healthResult, visual: null });
      return;
    }

    // 3. 视觉监控
    const visualResult = await performVisualMonitor(screenId, screen, platform);

    // 4. 记录结果
    results.push({
      path: screen.path,
      health: healthResult,
      visual: visualResult,
    });
  });
}

架构总览

层级职责

  • Test Suite 编排测试流程,收集结果
  • Health Checker 检测功能性错误
  • Visual Monitor 检测视觉差异
  • CDP Actions 封装 CDP 操作
  • CDP Client 管理 WebSocket 连接
  • Inject LayerApp 端注入的测试接口

报告生成

终端实时输出

┌────────────────────────────────────────────────────────────┐
│                  P00 Page Inspection                        │
└────────────────────────────────────────────────────────────┘

[1/312] /calendar/view
  ✅ Health: PASS
  ✅ Visual: PASS

[2/312] /customer/detail
  ✅ Health: PASS (Toast: "Customer loaded")
  ⚠️  Visual: WARN (3.2% diff, threshold 5%)

[3/312] /payment/flow
  ❌ Health: FAIL
  ❗ Error: ErrorBoundary: Cannot read property 'amount' of undefined

...

═══════════════════════════════════════════════
              P00 Test Report
═══════════════════════════════════════════════
  Platform:  ios
  Duration:  245.3s
───────────────────────────────────────────────
  Total:     312
  ✅ Pass:   298
  ⚠️  Warn:   5
  ❌ Fail:   2
  ⏭️  Skip:   7
───────────────────────────────────────────────
  Pass Rate: 97.1%
═══════════════════════════════════════════════

JSON 详细报告

{
  "summary": {
    "platform": "ios",
    "total": 312,
    "pass": 298,
    "warn": 5,
    "fail": 2,
    "skip": 7,
    "duration": 245300
  },
  "pages": [
    {
      "path": "/calendar/view",
      "status": "PASS",
      "health": {
        "status": "PASS"
      },
      "visual": {
        "status": "PASS"
      },
      "duration": 1234
    },
    {
      "path": "/customer/detail",
      "status": "WARN",
      "health": {
        "status": "PASS",
        "toastMessages": ["Customer loaded"]
      },
      "visual": {
        "status": "WARN",
        "diffPercent": 3.2,
        "threshold": 5.0,
        "diffImagePath": "artifacts/diff/ios/customer-detail.png"
      },
      "duration": 2456
    },
    {
      "path": "/payment/flow",
      "status": "FAIL",
      "health": {
        "status": "FAIL",
        "errors": [
          "ErrorBoundary: Cannot read property 'amount' of undefined"
        ]
      },
      "visual": null,
      "duration": 567
    }
  ]
}

避坑指南

prepareForScreenshot 必须等待足够长

// ❌ 错误: 等待时间太短
await evaluateCode('window.clearToast?.()');
await new Promise((resolve) => setTimeout(resolve, 500));  // 不够!

// ✅ 正确: 等待 UI 稳定
await evaluateCode('window.clearToast?.()');
await new Promise((resolve) => setTimeout(resolve, 1500));  // 足够

原因 Toast 消失动画、键盘收起动画都需要时间。

current/ 目录必须每次运行前清空

// ❌ 错误: 不清空,旧文件残留
// 结果: 如果某个页面被跳过,current/ 还有上次的截图

// ✅ 正确: 运行前清空
beforeAll(() => {
  cleanupArtifacts(platform);
});

Toast 拦截器必须早于任何业务代码

// ❌ 错误: 在 App.tsx 中拦截
// 问题: 某些页面可能在 App 初始化前就显示 Toast

// ✅ 正确: 在 index.js 中加载 inject
if (process.env.EXPO_PUBLIC_IS_E2E === 'true') {
  import('./e2e/lib/inject/index').then(init);
}

阈值设置需要实际测试后调整

// ❌ 错误: 所有页面都用 1%
// 问题: 动态页面会有大量误报

// ✅ 正确: 根据页面类型分级
{
  path: '/calendar/view',
  category: 'dynamic',  // 5% 阈值
}

odiff 退出码判断

// ❌ 错误: 只看 stdout
const output = execSync(command).toString();

// ✅ 正确: 通过 try-catch 判断退出码
try {
  execSync(command);
  return { hasDiff: false };
} catch (error) {
  return { hasDiff: true };
}

总结

通过双层防护体系,我实现了:

对比维度
以前 (纯截图对比)
现在 (双层防护)
误报率
❌ 高 (数据变化、地图等)
✅ 低 (功能错误优先)
错误检测
❌ 只能检测视觉差异
✅ 检测 RN 红屏、ErrorBoundary、Toast
CI 阻塞
❌ 任何像素差异都阻塞
✅ 只有真正的 bug 阻塞
可追溯性
❌ 只有 diff 图
✅ 完整的 JSON 报告 + Toast 记录
覆盖率
❌ 手动测试,覆盖率低
✅ 自动化,300+ 页面全覆盖
执行时间
❌ 数小时 (手动)
✅ 5 分钟 (自动化)

核心设计理念

  1. 健康检查为红线严格检测功能性错误
  2. 视觉监控为警示宽松提醒 UI 变化
  3. 分离关注点两者互不干扰,各司其职
  4. 完整审计所有截图、日志、Toast 都记录

这套机制构成了我们 E2E 测试的核心防护网,确保每次代码变更都不会破坏现有功能。


参考

技术实践 APP开发 React Native E2E测试