Home
Perfecto的头像

Perfecto

React Native + Detox:iOS 模拟器环境配置

AI 速览

这篇文章记录了在 iOS 模拟器上运行 Detox E2E 测试时遇到的各种”坑”及其解决方案。其中最有价值的是对问题 5(Metro 未知目标设备)的排查过程:从日志中看到 Metro 报告 “Unexpected request from unknown page”,最终发现是 iOS 模拟器使用了与 Android 不同的 Deep Link 处理机制。解决方案是采用两阶段启动策略——先用 Detox 启动 App 建立原生层连接,再通过 Deep Link 加载 JS Bundle。文章还提供了一份实用的快速排查检查清单盖了构建、网络、连接、日志等常见问题


系列文章导航


环境与目标

组件
版本
iOS Simulator
iPhone 16 (iOS 18)
Detox
20.42.0
Expo SDK
~51.0.39
expo-dev-client
~4.0.26

目标本地 iOS 模拟器上成功运行 E2E 测试。

测试策略

  • 本地开发:iOS 模拟器(开发体验好,调试方便)
  • 云端 CI:BrowserStack Android(成本考量)

问题 1:applesimutils 未安装

现象

Command failed: applesimutils --version
/bin/sh: applesimutils: command not found

原因 applesimutils` 是 Detox 在 iOS 上的必需依赖,用于与模拟器交互(安装 App、重置状态、权限管理等)。

解决

brew tap wix/brew
brew install applesimutils

问题 2:binaryPath 路径错误

现象

CommandError: No development build (com.moement.moego.business) for this project is installed.

排查过程

  1. 误以为 expo start 会安装 App,原来只是启动 Metro bundler
  2. Detox 测试命令才会自动安装 App detox test
  3. 最终发现是 .detoxrc.js 中的 binaryPath 路径配置错误

解决正 .detoxrc.js 中的路径:

// 错误
"binaryPath": "ios/build/Products/Debug-iphonesimulator/business.app"

// 正确(注意多了一层 Build 目录)
"binaryPath": "ios/build/Build/Products/Debug-iphonesimulator/business.app"

问题 3:Metro 服务器未启动

现象

Error: connect ECONNREFUSED 127.0.0.1:8081

原因 etox 的 CDP bridge 需要连接 Metro Dev Server(端口 8081),但 Metro 未启动。

解决要两个终端协作,一个负责 expo start ,一个负责 detox test

# 终端 1:启动 Metro(E2E 模式)
pnpm start:e2e
# EXPO_PUBLIC_IS_E2E=true BRANCH_NAME=$(git branch --show-current) CI_ACTION=LOCALLY_DEV expo start
# 注意:不要按 i,那是安装 Expo Go,不是 E2E 包

# 终端 2:运行测试
# detox build --configuration ios.sim.debug
pnpm test:run-e2e-ios
# Detox 会自动安装构建好的 App

问题 4:ADB 命令在 iOS 环境报错

现象

Command failed: adb shell settings put global window_animation_scale 0
/bin/sh: adb: command not found

原因码中有 Android 专用的 ADB 命令,在 iOS 环境下执行会失败。

解决加平台判断和错误捕获:

if (device.getPlatform() === 'android') {
  for (const command of adbCommands) {
    try {
      execSync(command, { stdio: 'ignore' });
    } catch {
      // 防止 ADB 失败导致整个测试崩溃
    }
  }
}

问题 5:Expo Dev Client 启动时序问题(核心问题)

这是整个排查过程中最复杂的问题,花费了大量时间定位。

现象

ReferenceError: Property 'login' doesn't exist

登录用例跑不起来。

排查过程(走了不少弯路)

第一阶段:误判方向

最初以为是 window.login 注入失败。检查了 e2e/bridge/inject.ts

(window as any).login = async (email: string, password: string) => {
  await AsyncStorage.setItem(LK_MOE_STRIPE_CONFIRM_LAUNCH_FLAG, '1');
  await store.dispatch(login({ byEmailPassword: { email, password } }));
  // ...
};

检查 index.js 的条件加载逻辑:

if (process.env.EXPO_PUBLIC_IS_E2E) {
  import('./e2e/bridge/inject').then(init);
} else {
  init();
}

误以为是 EXPO_PUBLIC_IS_E2E 环境变量未正确传递,导致注入缺失。

尝试删除 if 语句强制加载 inject.ts,问题依旧。问了同事 Rex,说和这个 inject 文件没关系。开始排查其他方向。

第二阶段:发现真正的异常

多次运行 detox test --configuration ios.sim.debug,发现一个关键现象:

每次 App 启动后,Metro 终端没有任何 Bundle 日志,App 会停留在 Expo Dev Client 的 “Development servers” 页面。

观察 Dev Client 页面显示:

  • http://localhost:8081 (绿点,表示可连接)
  • “Fetch development servers”
  • “Enter URL manually”

手动点击 http://localhost:8081,Metro 终端开始构建,模拟器顶部显示 “Building…”,一段时间后进入应用页面。

结论 ev Client 能检测到 Metro server,但不会自动连接。

第三阶段:尝试 Deep Link 自动连接

参考 Detox 文档 Mocking Open With URL (Deep Links),尝试通过 launchApp({ url }) 自动完成连接:

// 尝试 1
await device.launchApp({ url: 'exp://localhost:8081' });

// 尝试 2
await device.launchApp({ url: 'http://localhost:8081' });

结果更糟:不传 URL 时还能进入 Development servers 页面,传 URL 后直接卡在 Splash 页面(启动 Logo 画面)。

第四阶段:寻找正确的 URL Scheme

通过 grep 搜索 Info.plist

grep -r "CFBundleURLSchemes" ios --include="*.plist" -A 5

ios/MoeGo2/Info.plist 中找到:

<key>CFBundleURLSchemes</key>
<array>
  <string>exp+moego-business-2</string>
</array>

结合 Expo 官方文档 Deep Linking to an Updates URL,Expo Dev Client 的 Scheme 格式为 exp+{slug}slug 来自 app.config.ts

export default (_ctx: ConfigContext) => {
  return {
    slug: 'moego-business-2',
  }
}

构建完整的 Deep Link URL:

const scheme = 'exp+moego-business-2';
const metroUrl = encodeURIComponent('http://localhost:8081');
const deepLinkUrl = `${scheme}://expo-development-client/?url=${metroUrl}`;
// 结果: exp+moego-business-2://expo-development-client/?url=http%3A%2F%2Flocalhost%3A8081

使用这个 URL 调用 launchApp,依旧卡在 Splash 页面,Metro 终端没有构建日志。

第五阶段:验证 Deep Link 本身是否可用

新开终端,手动执行:

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

结果:Metro 终端显示构建被触发,模拟器顶部显示 “Building…”,最终正常进入登录页。

结论 eep Link 功能正常,问题出在 Detox 的启动方式上。

第六阶段:定位根因

对比两种启动方式的行为差异:

方式
行为
结果
launchApp({ url })
XCUITest 启动 App 的瞬间发送 URL
卡死
xcrun simctl openurl
系统级调用,等待 App 就绪后分发
正常

推测时序问题:

解决方案:两阶段启动策略

将”启动 App”和”发送 Deep Link”分离,预留初始化时间:

// ============================================
// 两阶段启动策略
// 解决 Expo Dev Client + Detox 的时序问题
// ============================================

// 阶段一:启动 App,不带 URL
// 让 Expo Dev Client 完成原生模块初始化
await device.launchApp({
  newInstance: true,
  launchArgs: {
    EXPO_PUBLIC_IS_E2E: 'true',
  },
});

// 等待 Expo Dev Client 初始化完成
log.info('Waiting for Expo Dev Client to initialize...');
await new Promise((resolve) => setTimeout(resolve, 3000));

// 阶段二:发送 Deep Link 触发 Metro 连接
const scheme = 'exp+moego-business-2';
const metroUrl = encodeURIComponent('http://localhost:8081');
const deepLinkUrl = `${scheme}://expo-development-client/?url=${metroUrl}`;

log.info('Sending Deep Link to trigger Metro connection...');
await device.openURL({ url: deepLinkUrl });

// 等待 JS Bundle 加载完成
await waitFor(element(by.text('Log in')))
  .toBeVisible()
  .withTimeout(60000);

时序图


问题 6:跨平台 UI 选择器兼容

现象

Unknown class "com.facebook.react.views.textinput.ReactEditText"

原因分析

  1. 类名不兼容代码使用了 Android 特有的 ReactEditText,iOS 应为 RCTUITextField

React Native 跨平台原生组件映射

组件
Android
iOS
TextInput
ReactEditText
RCTUITextField
Text
ReactTextView
RCTText
View
ReactViewGroup
RCTView
ScrollView
ReactScrollView
RCTScrollView

解决方案

// ============================================
// 跨平台工具函数
// ============================================

/
 * 跨平台 TextInput 选择器
 * 暂时使用 type 选择器,后续应使用 testID 保证一致性
 */
const getTextInputSelector = () => {
  return device.getPlatform() === 'ios'
    ? by.type('RCTUITextField')
    : by.type('com.facebook.react.views.textinput.ReactEditText');
};

问题 7:App busy / Run loop 同步问题

现象

测试到某个页面后卡住不动,终端重复报错:

The app is busy with the following tasks:
• Run loop "Main Run Loop" is awake.
• 2 enqueued JavaScript timers.

原因

Detox 的同步机制会等待 App 进入”空闲”状态才继续执行。某些页面存在持续的动画、定时器或轮询,导致 Detox 认为 App 永远处于繁忙状态。

常见触发场景:

  • 倒计时组件
  • 无限循环动画(如 Loading spinner)
  • 轮询请求(如实时数据刷新)
  • setInterval 未清理

解决方案

方案 1:临时禁用同步试特定页面时)

await device.disableSynchronization();
// 执行不稳定页面的测试
await device.enableSynchronization();

方案 2:代码层面优化

在 App 代码中,E2E 模式下跳过持续动画:

const shouldAnimate = !process.env.EXPO_PUBLIC_IS_E2E;

<Animated.View
  style={shouldAnimate ? animatedStyle : staticStyle}
/>

对于接口轮询,需要开发者配置接口通配符跳过等待,根据特定的端点(以 URL 正则表达式表示),排除与网络活动相关的同步(即,在测试执行过程中,无需等待网络空闲即可继续执行):

await device.setURLBlacklist(['.*127.0.0.1.*', '.*my.ignored.endpoint.*']);

当前状态时注释掉卡顿的页面测试步骤,后续单独排查。

参考文档:


问题 8:系统权限弹窗干扰截图

现象

访问涉及地图组件的页面时,弹窗”允许 MoeGo 2 访问你的位置吗”,后续所有截图都会带上这个弹窗。

解决方案

Detox 提供了预授权机制,在 launchApp 时预设权限:

await device.launchApp({
  newInstance: true,
  permissions: {
    location: 'inuse',      // 允许使用时定位
    notifications: 'YES',   // 允许通知
    camera: 'YES',          // 允许相机
    photos: 'YES',          // 允许相册
  },
  launchArgs: {
    EXPO_PUBLIC_IS_E2E: 'true',
  },
});

参考文档:Detox Permissions API


问题 9:CDP 连接 IP 地址不匹配

现象

getCurrentRoute failed: Error: get cdp client error:
Error: connect EHOSTUNREACH 192.168.0.236:8081 - Local (192.168.0.243:59446)

原因分析

CDP (Chrome DevTools Protocol) 客户端连接流程:

1. CDP 客户端连接 localhost:8081 获取 /json 目标列表 ✅
2. Metro 返回的目标 WebSocket URL 包含 192.168.0.236:8081
3. CDP 尝试连接这个 IP → EHOSTUNREACH ❌

问题在于:

  • Metro 启动时检测到的网络 IP 是 192.168.0.236
  • 运行测试时网络环境已变化(WiFi 切换、IP 重新分配)
  • 实际 IP 变成了 192.168.0.243

解决方案

重启 Metro 服务器,让它重新检测当前网络 IP:

# 终端 1:重启 Metro
# Ctrl+C
pnpm start:e2e

# 终端 2:重新运行测试
pnpm test:run-e2e-ios

快速排查检查清单

再次遇到问题时,按以下顺序排查:

检查项
命令/位置
applesimutils 是否安装
applesimutils --version
binaryPath 是否正确
.detoxrc.js → ios/build/Build/Products/...
Metro 是否运行
lsof -i :8081
URL Scheme 是否正确
grep CFBundleURLSchemes ios//Info.plist
两阶段启动等待时间
初始化等待建议 3-5 秒
权限预授权
launchApp({ permissions: {...} })
IP 地址是否一致
重启 Metro 解决

总结

本次踩坑的核心收获:

  1. Expo Dev Client + Detox 的集成两阶段启动策略,这是一个比较隐蔽的时序问题
  2. 跨平台测试抽象选择器和交互方法,后续应全面使用 testID
  3. CDP 通信正确的网络环境,IP 变化需要重启 Metro
  4. 同步机制刃剑,需要在稳定性和测试覆盖率之间权衡
技术实践 APP开发 React Native E2E测试