Home
Perfecto的头像

Perfecto

React Native + Detox:Bridge 层代码注入实践

AI 速览

这篇文章讲述了如何在 React Native 应用中”植入间谍”——通过代码注入暴露内部 API 供 E2E 测试调用。文章的核心洞察是利用 ES6 Module Live Binding 特性实现全局生效的 Monkey Patching:由于 ES6 模块导出的是引用而非值,在入口处替换 Toast 等方法后,所有使用该方法的代码都会自动调用替换后的版本。另一个亮点是条件加载机制,通过环境变量确保注入代码只在 E2E 测试环境加载,不会影响生产包。文章还深入探讨了参数序列化的边界情况,如 Redux Store 和 Navigation State 中的循环引用问题,并提供了 safeStringify() 解决方案


系列文章导航


背景

在 基于 CDP 的信号同步机制 中,我解决了 “如何与 App 通信” 的问题,通过 CDP 协议建立了 Detox 测试进程与 React Native App 进程之间的通信管道。

但还有一个更本质的问题:我们要通过这个管道传输什么内容?

在标准的 E2E 测试中,Detox 只能通过 UI 层操作 App,例如 element(by.id('loginBtn')).tap()。这种方式在某些场景下效率极低:

场景
Detox 原生 API
我需要的能力
自动登录
❌ 逐个填写表单
✅ 直接调用 Redux Action
页面导航
❌ 逐级点击菜单
✅ 直接调用 Navigation API
检测 Toast
❌ 无法获取 Toast 文本
✅ 读取 window 变量
清理 UI 状态
❌ 无法隐藏键盘/清除焦点
✅ 调用 React Native API

这就是 灰盒测试 (Gray Box Testing) 的场景。根据 Detox 官方文档,Detox 是一个灰盒测试框架:

Gray Box Testing:结合了黑盒测试和白盒测试的特点。测试者了解应用的内部结构,但主要通过外部接口进行测试。Detox 能够监控应用内部的异步操作(网络请求、动画、React Native bridge 等),自动等待这些操作完成后再进行断言,从而减少测试的不稳定性。

要实现这些能力,我需要在 App 启动时注入一些”间谍”代码,暴露内部 API 供 Detox 调用。这就是 Bridge 层 (Inject) 的作用。

架构全景图:

本文将详细讲解如何设计和实现这套注入机制,涉及的关键技术包括:

  • Monkey Patching (运行时替换函数)
  • ES6 Module Live Binding (模块导出的动态绑定特性)
  • React Native Bundle 初始化顺序
  • 全局对象污染控制

环境

  • React Native: 0.74.5
  • Expo SDK: ~51.0.39
  • Detox: 20.42.0
  • Hermes Engine: (React Native 内置)
  • Metro Bundler: (React Native 内置)
  • CDP Client: chrome-remote-interface 0.33.2

问题 1: 如何在 E2E 环境下加载注入代码

现象

最直观的做法是在 App 入口文件 (index.js) 中直接引入 inject 模块:

// ❌ 问题方案:无条件加载
import { registerRootComponent } from 'expo';
import App from './App';
import './e2e/lib/inject';  // ❌ 生产环境也会加载

registerRootComponent(App);

问题:

  • ✅ E2E 环境可以正常工作
  • ❌ 生产包体积增加 (包含了 e2e 目录下的所有代码)
  • ❌ 生产环境暴露了调试 API (如 window.login),存在安全风险
  • ❌ 污染全局对象,可能影响业务代码

解决方案: 条件加载 + 环境变量

使用 process.env.EXPO_PUBLIC_IS_E2E 环境变量来控制是否加载注入代码:

// ✅ 正确方案:条件加载
import { registerRootComponent } from 'expo';
import App from './App';

const init = () => {
  registerRootComponent(App);
};

if (process.env.EXPO_PUBLIC_IS_E2E === 'true') {
  // 动态导入,Metro 构建时会自动处理
  import('./e2e/lib/inject/index').then(() => {
    console.log('[Setup] E2E inject modules loaded');
    init();
  });
} else {
  init();
}

关键点:

  1. 环境变量控制: EXPO_PUBLIC_IS_E2E 在构建时由 Metro 替换为常量
  2. 动态导入: 使用 import() 而非 require(),支持异步加载
  3. Tree Shaking: 生产构建时,if 语句会被优化掉,不包含 e2e 代码
  4. 初始化顺序: 等待 inject 模块加载完成后再注册 App

构建时行为:

# E2E 构建
EXPO_PUBLIC_IS_E2E=true pnpm test:build-e2e-ios
# → inject 代码会被打包

# 生产构建
EXPO_PUBLIC_IS_E2E=false pnpm build:production
# → inject 代码不会被打包 (Tree Shaking)

问题 2: Toast 拦截的技术挑战 —— Monkey Patching 与 Live Binding

现象

项目中有两种使用 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 (网络请求错误处理) 中使用的是方式 2,导致 API 错误提示无法被 E2E 测试捕获:

// src/utils/http/initHTTP.ts (业务代码)
import { Toast } from '@ant-design/react-native';

function handleApiError(error: AxiosError) {
  Toast.fail(error.message);  // ❌ E2E 测试无法检测到这条 Toast
}

方案探索

方案 1: 修改业务代码 (不可行)

// ❌ 需要修改所有使用 Toast 的地方
import { toast } from '@/utils/toast';
Toast.fail('请求失败');  // 替换为 toast('请求失败')

问题:

  • 影响范围大 (50+ 文件)
  • 违反”测试不应影响业务代码”原则
  • 后续新代码可能继续使用 Toast.fail

方案 2: Monkey Patching (可行)

在 App 启动时,运行时替换 @ant-design/react-native 中的 Toast 方法:

// e2e/lib/inject/interceptors.ts
import { Toast } from '@ant-design/react-native';

export const setupToastInterceptor = () => {
  // 保存原始方法
  const originalFail = Toast.fail;

  // 全局存储
  window._toastHistory = [];
  window._currentToastMessage = '';

  // 替换方法
  Toast.fail = (content, duration, onClose) => {
    const message = typeof content === 'string' ? content : JSON.stringify(content);

    // 记录到全局变量
    window._toastHistory.push(message);
    window._currentToastMessage = message;

    // 调用原始方法
    return originalFail(content, duration, () => {
      window._currentToastMessage = '';  // Toast 自动消失时清空
      if (onClose) onClose();
    });
  };
};

效果:

  • ✅ 不修改业务代码
  • ✅ 统一捕获所有 Toast 调用
  • ✅ 仅在 E2E 环境生效

核心原理: ES6 Module Live Binding

关键问题: 为什么 Monkey Patching 能影响已经 import 的代码?

这涉及 ES6 模块系统的一个重要特性

Binding (动态绑定)。

根据 React Native 官方文档, React Native 使用 Metro Bundler 编译 ES6 模块,模块导出是动态绑定的:

// @ant-design/react-native/lib/toast/index.js (简化版)
export const Toast = {
  fail: (content) => { /* 原始实现 */ }
};

// src/utils/http/initHTTP.ts
import { Toast } from '@ant-design/react-native';
// 此时 Toast.fail 是一个"指针",指向 Toast 对象的 fail 属性

// e2e/lib/inject/interceptors.ts
import { Toast } from '@ant-design/react-native';
Toast.fail = newImplementation;  // 修改同一个对象的 fail 属性

// initHTTP.ts 中的 Toast.fail 会"看到"新的实现

时序图:

时间线:
├── T0: Metro 构建 App Bundle
│       @ant-design 模块被打包

├── T1: App 启动,执行 index.js
│       import './e2e/lib/inject/index'

├── T2: 执行 setupToastInterceptor()
│       Toast.fail = newImplementation
│       ↓
│       此时,@ant-design 模块导出的 Toast.fail 已被替换

├── T3: 执行业务代码 initHTTP.ts
│       import { Toast } from '@ant-design/react-native'
│       ↓
│       Toast.fail('error') → 调用的是 newImplementation ✅

对比 CommonJS (不支持 Live Binding):

// 如果使用 CommonJS (require)
const Toast = require('@ant-design/react-native').Toast;
// Toast 是一个"快照",后续修改不会生效

Toast.fail = newImplementation;  // ❌ 对已 require 的代码无效

参考资料:

完整实现

// e2e/lib/inject/interceptors.ts
import { Toast } from '@ant-design/react-native';

export const setupToastInterceptor = () => {
  // 存储原始方法
  const originalMethods = {
    info: Toast.info,
    success: Toast.success,
    fail: Toast.fail,
    loading: Toast.loading,
    offline: Toast.offline,
  };

  // 全局存储
  window._toastHistory = [];
  window._currentToastMessage = '';

  // 拦截所有 Toast 方法
  (['info', 'success', 'fail', 'loading', 'offline'] as const).forEach((type) => {
    Toast[type] = (content, duration, onClose) => {
      // 安全序列化
      const message = typeof content === 'string'
        ? content
        : JSON.stringify(content);

      // 记录历史 (最多保留 20 条)
      window._toastHistory.push({ type, message, timestamp: Date.now() });
      if (window._toastHistory.length > 20) {
        window._toastHistory.shift();
      }

      // 更新当前 Toast
      window._currentToastMessage = message;

      // 调用原始方法
      return originalMethods[type](content, duration, () => {
        window._currentToastMessage = '';  // 清空当前 Toast
        if (onClose) onClose();
      });
    };
  });

  // 提供查询接口
  window.getToastHistory = () => window._toastHistory;
};

Detox 端调用:

// e2e/lib/actions/toast.ts
import { getCdpClient } from '../cdp/client';

export async function getCurrentToast(): Promise<string> {
  const client = await getCdpClient();
  const resp = await client.Runtime.evaluate({
    expression: `window._currentToastMessage`,
    returnByValue: true,
  });
  return resp.result.value as string;
}

export async function getToastHistory(): Promise<Array<{ type: string; message: string }>> {
  const client = await getCdpClient();
  const resp = await client.Runtime.evaluate({
    expression: `JSON.stringify(window.getToastHistory())`,
    returnByValue: true,
  });
  return JSON.parse(resp.result.value as string);
}

问题 3: 全局对象污染与 TypeScript 类型安全

现象

在 inject 代码中,我向 window 对象添加了大量属性:

// e2e/lib/inject/auth.ts
window.login = async (email, password, opId) => { /* ... */ };

// e2e/lib/inject/navigation.ts
window.navigate = async (path, params, opId) => { /* ... */ };

// e2e/lib/inject/helpers.ts
window._consoleLogs = [];
window._toastHistory = [];
window._currentToastMessage = '';
window.globalNavigation = navigationRef;
window.store = storeInstance;

问题:

  • ❌ TypeScript 类型检查报错: Property 'login' does not exist on type 'Window'
  • ❌ 代码提示缺失
  • ❌ 容易与业务代码的全局变量冲突

解决方案: TypeScript 类型扩展

创建类型定义文件,扩展 Window 接口:

// e2e/lib/inject/types.ts
import type { NavigationContainerRef } from '@react-navigation/native';

declare global {
  interface Window {
    // ========== 数据存储 ==========
    _consoleLogs: Array<{
      type: 'log' | 'warn' | 'error' | 'info' | 'debug';
      args: string[];
      timestamp: number;
    }>;
    _consoleLogsInitialized: boolean;
    _toastHistory: Array<{
      type: 'info' | 'success' | 'fail' | 'loading' | 'offline';
      message: string;
      timestamp: number;
    }>;
    _currentToastMessage: string;

    // ========== 操作 API ==========
    /
     * 登录功能
     * @param email - 邮箱
     * @param password - 密码
     * @param opId - 操作 ID (用于同步信号)
     */
    login: (email: string, password: string, opId?: string) => Promise<void>;

    /
     * 页面导航
     * @param path - 路由路径 ( '/calendar/view')
     * @param params - 路由参数
     * @param opId - 操作 ID (用于同步信号)
     */
    navigate: (path: string, params?: Record<string, any>, opId?: string) => Promise<void>;

    /
     * 清除当前 Toast
     */
    clearToast: () => void;

    /
     * 隐藏键盘
     */
    hideKeyboard: () => void;

    /
     * 清除输入框焦点
     */
    clearFocus: () => void;

    /
     * 获取 Toast 历史记录
     */
    getToastHistory: () => Array<{
      type: string;
      message: string;
      timestamp: number;
    }>;

    // ========== 调试变量 ==========
    /
     * 全局 Navigation Ref
     */
    globalNavigation: React.RefObject<NavigationContainerRef<any>>;

    /
     * Redux Store
     */
    store: any;  // 或者使用具体的 Store 类型
  }
}

export {};  // 必须导出,使其成为模块

使用效果:

// ✅ TypeScript 不再报错
window.login('user@test.com', 'password');  // 有完整的类型提示和检查

// ✅ IDE 自动补全
window.navigate('/calendar/view', { id: 123 });

// ✅ 参数校验
window.login(123, 'password');  // ❌ TypeScript Error: Argument of type 'number' is not assignable to parameter of type 'string'

命名约定

为了避免与业务代码冲突,我遵循以下命名约定:

类型
前缀
示例
说明
内部数据
_
_consoleLogs, _toastHistory
下划线表示私有,不应被业务代码访问
操作 API

login, navigate
简洁易用
调试变量
global
globalNavigation
明确表示全局作用域

问题 4: React Native Bundle 初始化顺序与全局对象可用性

现象

在使用 CDP 调用 window.login() 时,偶尔会出现错误:

ReferenceError: Property 'login' doesn't exist

这说明 inject 代码尚未执行完成,全局 API 还未挂载。

根因分析: React Native 初始化时序

根据 React NativeMetro Bundler 文档, React Native 应用的启动顺序如下:

关键时间点:

T0: Native 层创建 Hermes 引擎 T1: global 对象已可用 (由 InitializeCore.js 设置) T2: 执行 index.js T3: 执行 inject/index.ts T4: window.login 等 API 可用 T5: React 组件树渲染

问题: Detox 测试在 T3 ~ T4 之间调用 window.login() 会失败。

解决方案: 等待 Inject 就绪

在 Detox 端添加一个等待机制,确保 inject 模块已完全加载:

// e2e/lib/inject/helpers.ts (App 端)
export const setupAuth = () => {
  window.login = async (email: string, password: string, opId?: string) => {
    // ... 登录逻辑
  };
  console.log('[Setup] Auth module initialized');
};
// e2e/jest.setup.ts (Detox 端)
import { getCdpClient } from './lib/cdp/client';

async function waitForInjectReady(timeout = 15000): Promise<void> {
  const client = await getCdpClient();
  const startTime = Date.now();

  while (Date.now() - startTime < timeout) {
    try {
      const resp = await client.Runtime.evaluate({
        expression: `typeof window.login === 'function' && typeof window.navigate === 'function'`,
        returnByValue: true,
      });

      if (resp.result.value === true) {
        console.log('[E2E] Inject modules ready');
        return;
      }
    } catch (error) {
      // 忽略错误,继续轮询
    }

    await new Promise(resolve => setTimeout(resolve, 500));  // 每 500ms 检查一次
  }

  throw new Error(`Inject modules not ready after ${timeout}ms`);
}

beforeAll(async () => {
  // ... 其他初始化 ...
  await waitForInjectReady(15000);
}, 60000);

改进版: 使用 Console 日志监听

由于我已经有 Console 日志监听机制,可以利用它来检测初始化完成。相比轮询方案,Console 监听有明显优势:

方案
原理
延迟
网络开销
轮询
每 500ms 调用 `Runtime.evaluate` 检查
最坏 500ms
多次 CDP 往返
Console 监听
事件驱动,inject 完成后立即收到通知
接近 0ms
一次 CDP 事件推送

Console 监听是事件驱动的,inject 模块输出标志性日志后 Detox 端立即收到通知,无需轮询:

// e2e/lib/inject/index.ts
console.log('All inject modules initialized');  // 标志性日志

// e2e/jest.setup.ts
import { listenToConsoleLogs } from './lib/listeners/console';

async function waitForInjectReady(timeout = 15000): Promise<void> {
  return new Promise((resolve, reject) => {
    const timeoutId = setTimeout(() => {
      cleanup();
      reject(new Error(`Inject modules not ready after ${timeout}ms`));
    }, timeout);

    const cleanup = listenToConsoleLogs((entry) => {
      if (entry.args[0] === 'All inject modules initialized') {
        clearTimeout(timeoutId);
        cleanup();
        resolve();
      }
    });
  });
}

问题 5: CDP 调用的参数传递与序列化陷阱

现象

在调用 window.navigate() 时,传递复杂参数时偶尔失败:

// Detox 端
await client.Runtime.evaluate({
  expression: `window.navigate('/customer/detail', { id: 123, data: { name: 'John' } })`,
  awaitPromise: false,
});
// ❌ SyntaxError: Unexpected token 'o' in JSON

根因分析

CDP 的 Runtime.evaluate 方法接受一个字符串作为 expression,这意味着我需要将参数序列化为 JavaScript 代码:

// ❌ 错误:直接拼接对象
const params = { id: 123, data: { name: 'John' } };
expression: `window.navigate('/path', ${params})`  // → "window.navigate('/path', [object Object])"

// ✅ 正确:JSON.stringify
expression: `window.navigate('/path', ${JSON.stringify(params)})`
// → "window.navigate('/path', {\"id\":123,\"data\":{\"name\":\"John\"}})"

陷阱 1: 字符串参数需要转义

// ❌ 错误:单引号冲突
const path = "/customer's-detail";
expression: `window.navigate('${path}')`  // → SyntaxError

// ✅ 正确:使用双引号或转义
expression: `window.navigate("${path}")`
// 或
expression: `window.navigate('${path.replace(/'/g, "\\'")}')`

陷阱 2: 循环引用序列化失败

// ❌ 错误:对象包含循环引用
const params = { self: null };
params.self = params;
JSON.stringify(params);  // → TypeError: Converting circular structure to JSON

// ✅ 解决:检测并移除循环引用
function safeStringify(obj: any): string {
  const seen = new WeakSet();
  return JSON.stringify(obj, (key, value) => {
    if (typeof value === 'object' && value !== null) {
      if (seen.has(value)) return '[Circular]';
      seen.add(value);
    }
    return value;
  });
}

实际场景: 循环引用并非只出现在人工构造的例子中,以下是常见的实际案例:

场景
原因
解决方案
React Navigation 的 navigation 对象
包含 parent 指向父导航器
仅传递必要的路由参数,不传递整个 navigation 对象
Redux action 中包含 getState() 结果
某些 reducer 的 state 可能有循环结构
使用 safeStringify() 或只传递必要字段
React 组件实例
_owner, _context 等内部属性形成循环
不要传递组件实例,只传递数据

完整实现

// e2e/lib/actions/navigate.ts
import { getCdpClient, generateOperationId, waitForSignal } from '../cdp/client';

/
 * 安全序列化参数
 */
function safeStringify(obj: any): string {
  const seen = new WeakSet();
  return JSON.stringify(obj, (key, value) => {
    if (typeof value === 'object' && value !== null) {
      if (seen.has(value)) return '[Circular]';
      seen.add(value);
    }
    return value;
  });
}

/
 * 导航到指定路径
 */
export async function navigate(path: string, params: Record<string, any> = {}): Promise<void> {
  const client = await getCdpClient();
  const opId = generateOperationId();

  // 先监听信号
  const signalPromise = waitForSignal(opId);

  // 安全构建表达式
  const paramsStr = safeStringify(params);
  const expression = `window.navigate(${JSON.stringify(path)}, ${paramsStr}, ${JSON.stringify(opId)})`;

  console.log(`[Navigate] Calling: ${expression}`);

  // 触发操作
  await client.Runtime.evaluate({
    expression,
    awaitPromise: false,
  });

  // 等待完成
  await signalPromise;
  console.log(`[Navigate] Completed: ${path}`);
}

架构总结: Bridge 层的三层模型

经过以上改进,我们的 Bridge 层形成了清晰的三层架构:

职责边界:

层次
职责
技术栈
运行环境
Inject
暴露内部 API,拦截系统调用
React Native, Monkey Patching
App 进程 (Hermes)
CDP
维持连接,传输命令和信号
chrome-remote-interface, WebSocket
Detox 进程 (Node.js)
Actions
封装操作,隐藏 CDP 细节
TypeScript
Detox 进程 (Node.js)

避坑指南

初始化顺序必须严格控制

// ✅ 正确顺序
setupEnvironment();      // 1. 先设置环境 (global, store)
setupUIHelpers();        // 2. 再设置 UI 工具
setupConsoleInterceptor(); // 3. 设置拦截器
setupToastInterceptor();
setupNavigation();       // 4. 最后设置业务功能
setupAuth();

// ❌ 错误:在 setupEnvironment 之前调用 setupAuth
// → auth.ts 中引用 store 会报错 undefined

Monkey Patching 必须在模块加载前执行

// ❌ 错误:业务代码已经执行
import App from './App';  // ← initHTTP.ts 已加载,Toast.fail 已绑定
import './e2e/lib/inject';  // ← 此时 Monkey Patch 无效

// ✅ 正确:先 Patch 再加载业务代码
import './e2e/lib/inject';
import App from './App';

CDP 参数必须正确序列化

// ❌ 错误:直接拼接对象
expression: `navigate('/path', ${params})`  // → [object Object]

// ✅ 正确:JSON.stringify
expression: `navigate('/path', ${JSON.stringify(params)})`

// ❌ 错误:字符串未转义
expression: `navigate('${path}')`  // 如果 path 包含单引号会出错

// ✅ 正确:使用双引号或转义
expression: `navigate("${path}")`

returnByValue 参数不可省略

// ❌ 错误:不加 returnByValue
await client.Runtime.evaluate({ expression: `window._consoleLogs` });
// → 返回 RemoteObject 引用,无法获取实际数据

// ✅ 正确:加 returnByValue + JSON.stringify
await client.Runtime.evaluate({
  expression: `JSON.stringify(window._consoleLogs)`,
  returnByValue: true,
});

环境变量必须在构建时注入

# ❌ 错误:运行时设置环境变量
export EXPO_PUBLIC_IS_E2E=true
pnpm start  # → Metro 构建时已确定,运行时修改无效

# ✅ 正确:构建时设置
EXPO_PUBLIC_IS_E2E=true pnpm test:build-e2e-ios

总结

通过引入 Bridge 层注入机制,我实现了 Detox 测试进程与 React Native App 进程之间的深度集成:

对比维度
传统 E2E (纯 UI 操作)
Bridge 层注入方案
登录速度
❌ 10~15 秒 (填写表单)
✅ 2~3 秒 (调用 API)
Toast 检测
❌ 无法获取文本
✅ 实时捕获所有 Toast
页面导航
❌ 逐级点击菜单
✅ 直接跳转到目标页面
调试能力
❌ 只能看到 UI 日志
✅ 查看 App 内部 console 日志
代码侵入性
✅ 无需修改业务代码
✅ 通过 Monkey Patch 零侵入
安全性
✅ 生产环境无影响
✅ 条件加载,生产不包含 inject 代码

核心技术要点:

  1. 条件加载: 使用 process.env.EXPO_PUBLIC_IS_E2E + 动态导入
  2. Monkey Patching: 运行时替换系统函数,利用 ES6 Module Live Binding 特性
  3. 类型安全: 扩展 Window 接口,提供完整的 TypeScript 类型支持
  4. 初始化同步: 通过 waitForInjectReady() 确保 inject 模块加载完成
  5. 参数序列化: 使用 safeStringify() 处理循环引用等边界情况

这套机制构成了我们 E2E 框架的操作能力基础,结合 基于 CDP 的信号同步机制 中的同步信号机制,我实现了一套完整的灰盒测试解决方案。

在 页面巡检的健康检查与视觉监控 中,我将深入探讨如何构建健康检查器 (Health Checker) 和视觉监控器 (Visual Monitor),实现 300+ 页面的自动化巡检。


参考文档

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