← 中文资源

简体中文版本

时间旅行测试

时间旅行测试把真实时钟换成可控时钟——瞬间推进跨过重试、订阅与过期——让时间性行为确定、可测且全速运行。

阅读英文原文

概念定义

时间相关行为——重试、过期、调度——是货真价实的逻辑,配得上真实的测试。挂钟时间让这些测试又慢又不确定;可控时钟让它们瞬时且精确。

工作机制

应用经注入接口读时间;测试替换成 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,
  };
}