Back

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

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

时间是测试可靠性的隐形杀手。一个在本地通过的测试在 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——改为等待特定条件。始终清理假计时器以防止测试污染。

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

常见问题

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

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

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

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

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. Check our GitHub repo and join the thousands of developers in our community.

OpenReplay