.NET 8 的 TimeProvider 配合一个简单的 SynchronizationContext 调整,可以大幅缩短重试逻辑单元测试的运行时间,避免测试因真实的休眠而处于闲置状态。原本需要 7 秒的测试现在只需几十毫秒即可完成,而且这种改动在生产环境中不会产生任何额外开销。
为什么延迟很重要
许多重试辅助工具使用指数退避(exponential backoff):等待 1 秒,然后是 2 秒,接着是 4 秒,依此类推。在生产环境中,这可以保护服务免受故障端点的持续冲击。但在测试套件中,相同的代码会在真实时钟上调用 Task.Delay,导致测试线程实际进入休眠。如果乘以数十个测试,持续集成(CI)流水线的运行时间就会无谓地增加数分钟。这种“休眠税”纯粹是在浪费时间。
TimeProvider 的工作原理
.NET 8 引入了 TimeProvider,它是对系统时钟的一种抽象。需要获取当前时间或需要延迟的类可以接受一个 TimeProvider 实例,而不是直接调用 DateTime.UtcNow 或 Task.Delay。在生产环境中,你传递 TimeProvider.System,它会转发到真实时钟。在测试中,你提供一个 FakeTimeProvider。这个伪造的提供者允许你使用 Advance(TimeSpan) 手动推进时间。在内部,Task.Delay 会读取该提供者,因此向前推进伪造时钟可以立即满足任何待处理的延迟。
核心思想很简单:用一个可控的时钟替换真实时钟,然后直接跳转到被测代码应当恢复执行的时间点。
xUnit SynchronizationContext 的陷阱
这个概念在控制台应用程序中可行,但在 xUnit 中会导致死锁。当重试辅助工具正在等待延迟时,测试调用了 Advance()。xUnit 安装了它自己的 SynchronizationContext,用于捕获后续操作(continuations)并在测试线程上运行。当 Advance() 推进时钟时,后续操作被排队到了线程池,而不是测试线程。测试线程继续推进时间,使模拟时钟超过了下一次重试应该触发的时间点。新的定时器被设置为一个永远无法到达的未来时间点,因为已经没有线程可以再次移动时钟了。测试陷入了无限期的挂起。
一行代码修复
在测试开始时添加一行代码即可恢复预期行为:
SynchronizationContext.SetSynchronizationContext(null);
清除自定义上下文会强制 await 的后续操作在线程池上运行,从而使伪造定时器的回调能够执行。通过这一改动,同一个测试从 7 秒缩短到了大约 36 毫秒。
更广泛的好处
除了消除闲置休眠外,伪造时钟还使得测试对时区敏感的逻辑变得非常容易。你可以验证每日配额是否在正确的当地午夜重置,而无需等待真实时钟走到十二点。同样的方法也适用于任何根据当前时间进行分支的逻辑:注入提供者,避免隐藏的 Thread.Sleep 或 Task.Delay 调用,你就能获得确定性且快速的测试。
注意事项
- 每个延迟都必须通过注入的
TimeProvider进行路由。任何遗漏的Thread.Sleep或直接的Task.Delay仍会调用真实时钟,从而重新引入延迟。 - 如果你依赖的库在内部调用了
Task.Delay但没有暴露提供者,除非使用更具侵入性的垫片(shim),否则你无法控制其定时。在这种情况下,收益可能有限。 - 在测试文件夹中搜索
Task.Delay和Thread.Sleep以发现隐藏的等待。将这些调用重构为使用提供者是保持伪造时钟有效的唯一方法。
总结
将系统时钟替换为 TimeProvider 并禁用 xUnit 的 SynchronizationContext,可以将缓慢的重试测试转变为近乎即时的检查,从而释放 CI 资源,并使依赖时间的代码更易于验证。这只需要几行代码的努力,而回报则是每次测试运行都能节省数秒时间。
