← 中文资源

简体中文版本

注入时钟而不是直接调用 Date.now

把时钟作为依赖传入领域代码,测试中手动推进时间,精确断言 TTL 边界而不是靠 sleep 等待。

阅读英文原文

概述

直接读取真实时钟的测试只能断言“还没过期”,或者 sleep 到过期。sleep 让测试变慢,而真正有价值的边界——TTL 前后一毫秒——永远不会被覆盖。更糟的是生产代码没有接缝:Date.now() 散落在五处,同一个请求内可能对“现在”得出不同结论。注入时钟可以同时解决这两个问题。

步骤

  1. 定义最小接口与生产实现:
export interface Clock { now(): number }
export const systemClock: Clock = { now: () => Date.now() };
  1. 把时钟作为参数传入,并明确边界是包含还是排除:
export function isExpired(issuedAt: number, ttlMs: number, clock: Clock = systemClock) {
  return clock.now() >= issuedAt + ttlMs;
}
  1. 测试中手动推进时间,不调用 sleep:
let now = 1_700_000_000_000;
const clock = { now: () => now };
now = 1_700_000_000_000 + 3_600_000 - 1;
assert.equal(isExpired(1_700_000_000_000, 3_600_000, clock), false);
  1. 用表驱动覆盖 -1ms、0、+1ms 三个偏移,把包含语义固定下来。
  2. 仅在重试、防抖等计时器代码中使用 fake timers,并在 finally 中恢复。

验证

三个偏移应分别得到 false、true、true,整套测试在毫秒级完成。把比较符从 >= 改成 > 后边界用例应立即失败。若某条路径仍调用 Date.now(),开启 lint 规则后应报错。

注意事项

  • fake timers 会让 Promise 与 IO 卡住,业务逻辑优先注入时钟,计时器测试才使用 tickAsync。
  • 注入时钟不能解决时区与日历运算,应使用显式时区的库。
  • 第三方库内部调用 Date 无法注入,只能在边界层测试。
  • 同一操作内应复用同一个时钟实例,避免多次读取导致不一致。
  • fake timers 可能掩盖真实并发问题,集成测试仍应使用真实时间。