你有多少次盯着测试运行,看着它慢吞吞地消耗真实时间,心里只想让它快一点?一个指数退避的重试测试,老老实实等着系统时钟走完1秒、2秒、4秒——这7秒钟,什么都没干,却每一轮都硬扛了下来。测试是正确通过了,但每次跑,心都在滴血。

被测的就是一个标准的瞬时故障重试助手。规则简单:连续三次超时失败后成功,中间各插入1s、2s、4s的等待。老老实实对着真实时钟写测试,长这样:

打开网易新闻 查看精彩图片

var retry = new TransientRetry(TimeProvider.System, maxAttempts: 4, baseDelay: TimeSpan.FromSeconds(1));
var calls = 0;
var result = await retry.ExecuteAsync(() =>
++calls < 4 ? throw new TimeoutException("flaky dependency")
: Task.FromResult("ok"));
Assert.Equal(4, retry.Attempts);
Assert.True(sw.Elapsed >= TimeSpan.FromSeconds(7));

一遍,七秒。每次运行,永远七秒。测试本身没错,甚至准确得让人讨厌。再算上失败终止路径的测试、抖动测试、取消令牌测试……一个不起眼的重试工具类,就能悄悄在 CI 流水线里吃掉半分钟。这就是“睡眠税”,多数测试套件里,默默交着。

从 .NET 8 开始,工具箱里多了一把正式的时钟抽象:TimeProvider。重试助手唯一要改的地方,就是接受一个 TimeProvider 并传给 Task.Delay。生产代码喂进去 TimeProvider.System,测试代码FakeTimeProvider 接手,直接用手拨动时间——fake.Advance(TimeSpan.FromSeconds(1))——一秒虚拟流逝,零秒真实等待。同样的重试逻辑,任务在每次 Advance 后仍然老老实实挂在后退上,直到第四次尝试成功,瞬间返回结果。整整七虚拟秒,真实耗时为 0。用控制台应用单独跑,完美通过,一切都像教科书一样优雅。

然后,把同一段代码贴进 xUnit 项目,敲下 dotnet test。预期的秒级结果没有出现,控制台静悄悄,一直静到挂起检测器抬手一枪——进程直接毙了。测试不但没有变快,干脆永远没醒过来。

一模一样的方法调用,一模一样的 FakeTimeProvider.Advance,在控制台里跑得像风,进了 xUnit 就死在那,没有任何异常,没有任何输出。那个本应“零秒通过”的测试,连“失败”两个字都来不及写出来,就直接从屏幕上彻底蒸发,只剩下一个被操作系统强行杀掉的进程。