12k
All articles

测试中的时间处理:异步和延迟的可靠模式

使用 fake timer 模式修复 Jest、Vitest、React Testing Library、Playwright 和 Cypress 中因计时器与异步时序引发的不稳定测试问题。

OpenReplay Team
OpenReplay Team
测试中的时间处理:异步和延迟的可靠模式

时间是测试可靠性的隐形杀手。一个在本地通过的测试在 CI 中失败了。一个原本几秒钟运行完的测试套件现在需要几分钟。一个运行完美的组件出现了幽灵般的失败,因为计时器和 Promise 无法协同工作。

本文介绍了现代异步测试时间模式假计时器最佳实践,涵盖 Jest、Vitest、React Testing Library、Playwright 和 Cypress——聚焦于 2025 年真正有效的方法。

核心要点

  • 使用异步计时器 API(advanceTimersByTimeAsync)而非同步变体,以防止 Promise/计时器死锁
  • 当结合假计时器与 Testing Library 的异步工具时,配置自动推进计时器
  • 始终在 afterEach 中清理假计时器,以防止测试污染和级联失败
  • 在 E2E 测试中,等待特定条件而不是使用任意延迟,如 waitForTimeoutcy.wait(ms)

为什么时间会破坏测试

测试在时间方面失败有三个原因:真实延迟减慢执行速度、时间变化导致不稳定性,以及假计时器与 Promise 冲突。

JavaScript 事件循环按特定顺序处理宏任务(setTimeoutsetInterval)和微任务(Promise 回调)。假计时器拦截宏任务但不会自动处理微任务。这种不匹配导致了大多数与计时器相关的测试失败。

Jest 异步计时器:现代方法

Jest 当前的假计时器实现底层使用 @sinonjs/fake-timers可靠异步测试的关键是理解应该一起使用哪些 API。

beforeEach(() => {
  jest.useFakeTimers();
});

afterEach(() => {
  jest.useRealTimers();
});

test('debounced search triggers after delay', async () => {
  render(<SearchInput />);
  await userEvent.type(screen.getByRole('textbox'), 'query');
  
  await jest.advanceTimersByTimeAsync(300);
  
  expect(mockSearch).toHaveBeenCalledWith('query');
});

关键细节:使用 advanceTimersByTimeAsync 而不是 advanceTimersByTime。异步变体在计时器执行之间刷新微任务,防止导致测试挂起的 Promise/计时器死锁。

对于动画,Jest 提供 jest.advanceTimersToNextFrame() 来逐步执行 requestAnimationFrame 回调,无需等待实际的帧时间。

Vitest 假计时器:Sinon 集成

Vitest 共享相同的 @sinonjs/fake-timers 基础,使得 Vitest 假计时器的行为与 Jest 类似,只有微小的 API 差异。

import { vi } from 'vitest';

beforeEach(() => {
  vi.useFakeTimers();
});

afterEach(() => {
  vi.useRealTimers();
});

test('toast disappears after timeout', async () => {
  render(<Toast message="Saved" />);
  
  await vi.advanceTimersByTimeAsync(3000);
  
  expect(screen.queryByText('Saved')).not.toBeInTheDocument();
});

两个框架都支持自动推进计时器,当事件循环空闲时自动推进时间。这消除了大多数测试中的手动时间控制管理。

React Testing Library 协调

假计时器与 Testing LibrarywaitFor 之间的交互经常引起混淆。当假计时器激活时,waitFor 会轮询但时间不会推进——造成无限循环。

解决方案:配置假计时器在异步等待期间自动推进。

jest.useFakeTimers({ advanceTimers: true });

这告诉 Jest 在 Promise 处于待定状态时推进计时器,允许 waitForfindBy 查询与假计时器自然协作。

对于 user-event,确保使用当前的 API。该库在内部处理自己的时间,并在正确配置时与假计时器协调。

E2E 时间控制:Playwright 和 Cypress

Playwrightpage.clock API 在浏览器上下文中提供精确的时间控制:

await page.clock.install({ time: new Date('2025-01-15') });
await page.clock.fastForward(5000);

Cypress 提供 cy.clock()cy.tick() 进行类似控制。这两种方法都优于 waitForTimeoutcy.wait(ms),后者会引入真实延迟和不稳定性。

导致不稳定的常见陷阱

测试之间的计时器泄漏:始终在 afterEach 中恢复真实计时器。泄漏的假计时器会在后续测试中造成级联失败。

第三方库冲突:动画框架或轮询工具等库会安排自己的计时器。这些可能不遵守你的假计时器安装,导致意外行为。

微任务/宏任务顺序:混合使用 setTimeoutPromise.resolve() 的代码在假计时器下表现不同。始终一致地使用异步计时器 API。

Temporal API 考虑:使用 Temporal 而不是 Date 的新代码库需要单独的模拟策略。抽象你的时间源以使测试与框架无关。

结论

优先使用异步计时器 API(advanceTimersByTimeAsync)而非同步变体。使用 Testing Library 异步工具时配置自动推进。避免在 E2E 测试中使用任意睡眠和 waitForTimeout——改为等待特定条件。始终清理假计时器以防止测试污染。

目标是测试运行快速、通过一致,并且不编码时间实现细节。自动推进的假计时器能让你完成大部分工作。对于其他情况,显式异步协调总是优于任意延迟。

常见问题

为什么在使用假计时器与 async/await 时我的测试会挂起?

当假计时器阻塞宏任务而 Promise 等待微任务解析时,测试会挂起。使用异步计时器方法如 advanceTimersByTimeAsync 而不是 advanceTimersByTime。异步变体在计时器执行之间刷新微任务,打破 Promise 和计时器之间的死锁。

如何让 waitFor 与 Jest 假计时器一起工作?

通过调用 jest.useFakeTimers({ advanceTimers: true }) 启用 advanceTimers 选项来配置 Jest。这告诉 Jest 在 Promise 处于待定状态时自动推进时间,允许 waitFor 和 findBy 查询正确轮询而不会创建无限循环。

在 E2E 测试中应该使用真实延迟还是假计时器?

通过 Playwright 的 page.clock 或 Cypress 的 cy.clock 和 cy.tick 使用假计时器。使用 waitForTimeout 或 cy.wait 的真实延迟会引入不稳定性,因为执行速度在不同环境中有所不同。相反,应等待特定的 UI 条件或使用受控的时间推进。

为什么我的测试在本地通过但在 CI 中因时间错误而失败?

CI 环境具有可变的 CPU 和内存资源,导致依赖时间的测试表现不一致。用假计时器替换真实延迟,使用等待条件而非固定持续时间的异步断言,并确保测试之间正确清理计时器以防止污染。

DevTools for the frontend

Gain Debugging Superpowers

Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers.

Star on GitHub12k

We use cookies to improve your experience. By using our site, you accept cookies.