PAYATHON 2026

Detox 自动化测试随机超时并卡住,如何解决?

支付小周

结论

这类随机超时通常不是 Jest 本身“失灵”,而是 Detox 一直在等应用进入可交互的空闲状态,或者测试代码里有异步操作没有正确 await。waitFor(...).withTimeout(5000) 只能限制元素条件的等待时间。如果 Detox 在执行 tap() 或 waitFor() 前,已经被持续动画、定时器或网络请求等同步资源阻塞,最后触发的仍是 Jest 的 40 秒超时。

同一个测试偶尔成功,也不能排除测试或应用存在竞态条件。排查时应先确认 Detox 正在等待哪些资源,再检查测试钩子和辅助函数的完整异步链路。自动重试可以兜底,但不能代替对阻塞源的修复。

为什么 withTimeout(5000) 没有及时失败

Detox 执行操作时,通常会经过以下过程:

  1. 等待应用进入空闲状态;
  2. 执行 tap()、匹配或断言;
  3. 等待目标条件满足。

因此,下面设置的 5 秒超时不一定会从调用这一行开始计时:

await waitFor(element(by.id('product-payment-history-1')))
  .toBeVisible()
  .withTimeout(5000);

如果应用一直被 Detox 判断为忙碌,调用可能还没进入元素等待阶段就卡住了。常见原因包括:

  • Animated 动画没有结束,或存在循环动画;
  • setInterval 持续运行,或短周期 setTimeout 不断重建;
  • 网络请求长时间没有完成;
  • WebSocket、轮询任务或后台任务持续刷新;
  • React Native bridge 上一直有工作;
  • 页面切换过程中出现竞态条件;
  • 上一个测试启动了异步任务,结束时却没有等待或清理;
  • beforeEach、afterEach 或辅助函数遗漏了 await 或 return。

Jest 最终只能报告“异步回调在 40000ms 内没有完成”,但这只是外层表现。

首先检查所有异步调用

检查范围不要局限在报错的测试,还应包括测试钩子、页面对象和公共辅助函数。

错误示例:

beforeEach(async () => {
  device.reloadReactNative(); // 缺少 await
});

async function openProduct() {
  element(by.id('product-1')).tap(); // 缺少 await
}

正确写法:

beforeEach(async () => {
  await device.reloadReactNative();
});

async function openProduct() {
  await element(by.id('product-1')).tap();
}

还要特别检查 forEach,因为它不会等待异步回调执行完毕:

items.forEach(async (item) => {
  await element(by.id(item.id)).tap();
});

应改为顺序执行:

for (const item of items) {
  await element(by.id(item.id)).tap();
}

如果这些操作允许并发,则应明确等待所有 Promise:

await Promise.all(
  items.map(async (item) => {
    await performAsyncPreparation(item);
  })
);

多个 Detox UI 操作不要并发执行。设备上的点击和输入通常需要保持串行。

查找让 Detox 一直等待的应用资源

开启详细日志或 trace 后,不要只看最后一个 invoke。还要检查测试卡住前,是否有某类同步资源反复出现。Detox 不同版本的日志参数和输出格式可能有差异,应以项目当前版本的 CLI 帮助和文档为准。

可以从这些方向排查:

  • 暂时关闭页面中的循环动画;
  • 停止轮询、WebSocket 或自动刷新;
  • 为网络请求设置明确的超时时间;
  • 在测试环境中 mock 不稳定的外部服务;
  • 检查页面卸载时是否清理 timer 和订阅;
  • 在每一步操作前后打印测试日志,确认具体卡在哪个 await。

例如,可以先拆分测试步骤来定位问题:

it('should go on the payment history view', async () => {
  console.log('before tapping payment history tab');

  await element(by.id('product-tab-2')).tap();

  console.log('after tapping payment history tab');

  await waitFor(element(by.id('product-payment-history-1')))
    .toBeVisible()
    .withTimeout(5000);

  console.log('payment history is visible');
});

如果 after tapping... 都没有输出,说明问题出在 tap() 内部。通常是 Detox 在点击前后等待应用空闲,而不是等待目标元素时超时。

修复持续动画和定时器

对测试结果没有影响的动画,最好在 E2E 环境中关闭:

const animationDuration = isE2ETest ? 0 : 300;

组件卸载时必须清理定时器:

useEffect(() => {
  const intervalId = setInterval(refreshData, 30000);

  return () => {
    clearInterval(intervalId);
  };
}, []);

持续轮询可以在 E2E 环境中禁用,或者在页面稳定后停止:

useEffect(() => {
  if (isE2ETest) {
    return undefined;
  }

  const intervalId = setInterval(refreshData, 30000);
  return () => clearInterval(intervalId);
}, []);

如果某个持续资源属于产品的正常行为,又确实无法关闭,可以在很小的范围内使用 Detox 的同步控制。相关 API 是否可用取决于 Detox 版本:

await device.disableSynchronization();

try {
  await element(by.id('product-tab-2')).tap();

  await waitFor(element(by.id('product-payment-history-1')))
    .toBeVisible()
    .withTimeout(5000);
} finally {
  await device.enableSynchronization();
}

不要让整个测试套件一直处于禁用同步的状态。这样会失去 Detox 自动等待的主要优势,还可能引入更多竞态问题。

让测试之间保持隔离

如果上一个测试遗留了页面状态、请求、动画或订阅,下一个测试就可能随机失败。可以根据执行成本,在 beforeEach 中重置应用状态:

beforeEach(async () => {
  await device.reloadReactNative();
});

如果需要更彻底的隔离,可以重新启动应用:

beforeEach(async () => {
  await device.launchApp({
    newInstance: true,
    delete: true,
  });
});

delete: true 会清除应用数据,可能明显拖慢测试,也会影响依赖登录状态或预置数据的用例。是否启用以及采用哪些启动参数,需要结合当前 Detox 版本和测试策略来确定。

清理逻辑本身也必须等待执行完成:

afterEach(async () => {
  await stopMockServerRequests();
  await clearTestSubscriptions();
});

简化当前测试

最后一条断言在逻辑上是重复的,因为 waitFor(...).toBeVisible() 已经确认元素可见。测试可以简化为:

it('should go on the payment history view', async () => {
  await element(by.id('product-tab-2')).tap();

  await waitFor(element(by.id('product-payment-history-1')))
    .toBeVisible()
    .withTimeout(5000);
});

保留独立断言来提高可读性也没有原则性问题,但它会再次触发匹配和同步检查,可能让阻塞位置更难定位。

能否自动重启整个测试集

可以把重试留作 CI 的最后一道保障。部分 Detox 版本提供测试重试参数,但参数名称和重试范围可能因版本、测试运行器和配置方式而异。应先查看:

detox test --help

如果当前版本支持套件重试,可以按照帮助信息进行配置。部分 Jest 版本也支持重试失败的测试:

jest.retryTimes(2);

使用重试时需要注意:

  • Jest 重试通常只针对失败的测试,不一定会重新创建模拟器或清除应用状态;
  • 如果超时是未清理的 timer、网络请求或原生资源造成的,直接重试仍可能失败;
  • 重跑整个套件会增加执行时间,也可能掩盖真实的竞态问题;
  • 重试前最好重新启动应用,必要时重新创建设备环境。

更可靠的处理顺序是:先通过日志找出卡住的 await,再检查遗漏等待的异步调用和持续占用同步状态的资源,最后增加有限次数的自动重试。

备注:内容仅供参考。