概念定义
时间相关行为——重试、过期、调度——是货真价实的逻辑,配得上真实的测试。挂钟时间让这些测试又慢又不确定;可控时钟让它们瞬时且精确。
工作机制
应用经注入接口读时间;测试替换成 fake:设定当前时刻、按增量推进、按序触发已排期回调。框架 fake 拦截 setTimeout 与 setInterval,让挂起计时器在推进时执行。真实时间依赖——NTP、文件时间戳、数据库 now()——需要各自的接缝。
使用场景
一切读时钟的逻辑:订阅续期、缓存与令牌 TTL、退避与抖动、类 cron 触发、时区换算。闰年、夏令时切换、月末这些边界表变成普通测试输入,而不是每年一次的生产实验。
常见误区
在业务逻辑深处直接调 Date.now 的代码从测试侧就无法控制时间。只冻结不够:多数 bug 住在“跨过过期、跨过 DST 边界、跨月翻转”的转移里,需要推进而非冻结。部分拦截会混合时钟域产出超现实失败;瞬时推进也藏住真实时间才暴露的顺序问题——要偶配真实时钟的浸泡测试;多节点分布时间需要模拟时钟服务而不是单个本地 fake。
代码示例
export class FakeClock {
constructor(start = 0) {
if (!Number.isFinite(start)) throw new TypeError("start must be a finite number");
this.now = start;
this.timers = [];
this.seq = 0;
}
setTimeout(fn, delay) {
if (!Number.isFinite(delay) || delay <= 0) {
throw new RangeError("delay must be a positive number");
}
const entry = { id: ++this.seq, due: this.now + delay, fn };
this.timers.push(entry);
this.timers.sort((a, b) => a.due - b.due || a.id - b.id);
return entry.id;
}
clearTimeout(id) {
this.timers = this.timers.filter((t) => t.id !== id);
}
advance(ms) {
if (!Number.isFinite(ms) || ms < 0) throw new RangeError("ms must be >= 0");
const target = this.now + ms;
for (;;) {
const next = this.timers[0];
if (!next || next.due > target) break;
this.timers.shift();
this.now = next.due;
next.fn();
}
this.now = target;
}
get pending() {
return this.timers.length;
}
}
export type Outcome<T> = { readonly ok: true; readonly value: T } | { readonly ok: false; readonly error: Error };
export function ok<T>(value: T): Outcome<T> {
return { ok: true, value };
}
export function fail(message: string): Outcome<never> {
return { ok: false, error: new Error(message) };
}
export interface AttemptLog {
readonly index: number;
readonly at: number;
readonly result: "ok" | "error";
}
export interface RetryHarness<T> {
readonly attempts: AttemptLog[];
readonly loader: () => Promise<T>;
readonly exhausted: () => boolean;
}
export function createHarness<T>(
script: readonly Outcome<T>[],
clock: { now: () => number; setTimeout: (fn: () => void, ms: number) => unknown },
): RetryHarness<T> {
const attempts: AttemptLog[] = [];
let index = 0;
const loader = async (): Promise<T> =>
new Promise<T>((resolve, reject) => {
const outcome = script[index];
attempts.push({
index,
at: clock.now(),
result: outcome.ok ? "ok" : "error",
});
index += 1;
if (outcome.ok) resolve(outcome.value);
else reject(outcome.error);
});
return {
attempts,
loader,
exhausted: () => index >= script.length,
};
}