概述
直接读取真实时钟的测试只能断言“还没过期”,或者 sleep 到过期。sleep 让测试变慢,而真正有价值的边界——TTL 前后一毫秒——永远不会被覆盖。更糟的是生产代码没有接缝:Date.now() 散落在五处,同一个请求内可能对“现在”得出不同结论。注入时钟可以同时解决这两个问题。
步骤
- 定义最小接口与生产实现:
export interface Clock { now(): number }
export const systemClock: Clock = { now: () => Date.now() };
- 把时钟作为参数传入,并明确边界是包含还是排除:
export function isExpired(issuedAt: number, ttlMs: number, clock: Clock = systemClock) {
return clock.now() >= issuedAt + ttlMs;
}
- 测试中手动推进时间,不调用 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);
- 用表驱动覆盖
-1ms、0、+1ms三个偏移,把包含语义固定下来。 - 仅在重试、防抖等计时器代码中使用 fake timers,并在
finally中恢复。
验证
三个偏移应分别得到 false、true、true,整套测试在毫秒级完成。把比较符从 >= 改成 > 后边界用例应立即失败。若某条路径仍调用 Date.now(),开启 lint 规则后应报错。
注意事项
- fake timers 会让 Promise 与 IO 卡住,业务逻辑优先注入时钟,计时器测试才使用
tickAsync。 - 注入时钟不能解决时区与日历运算,应使用显式时区的库。
- 第三方库内部调用 Date 无法注入,只能在边界层测试。
- 同一操作内应复用同一个时钟实例,避免多次读取导致不一致。
- fake timers 可能掩盖真实并发问题,集成测试仍应使用真实时间。