如何对依赖时间的代码进行单元测试

测试依赖当前时间的代码,会引出关于软件如何与外部状态交互的更深层设计问题。评论者比较了多种方法,例如依赖注入时钟、将时间戳传入纯函数、monkey-patch 时间 API,以及像 libfaketime 这样的系统级 shim,并指出不同语言以及单元测试与集成测试之间的选择各不相同。许多人认为,将时间视为一等依赖有助于提升可测试性和架构设计;另一些人则指出,当涉及第三方库、性能特性或真实硬件计时需求时,这种做法存在难以逾越的限制。

核心原则:把时间视为一种依赖

  • 许多人认为“时间和其他依赖一样”。
  • 推荐模式:注入一个时钟/计时器接口,传入一个 now() 函数,或向逻辑传入明确的时间戳。
  • 对于一些人来说,偏好顺序是:
    • 最佳:逻辑将时间 数据 作为参数接收。
    • 其次:逻辑接收一个产生时间的 函数
    • 然后:通过 DI 注入一个时钟/计时器对象。

特定语言和框架的工具

  • C++ 文章被认为范围较窄;其他语言提供了更容易的方法。
  • 动态语言(JS、Python、Ruby)通常会 monkey-patch 时间,或使用库:Jest fake timers、freezegun/time-machine(Python)、Timecop/Rails travel_to(Ruby)。
  • Java 通常注入 Clock。 .NET 8 增加了 TimeProviderFakeTimeProvider。Go 代码常把 time.Now 作为函数参数传递。

数据库和多个时钟

  • 在 SQL 中使用 NOW() 并不一定不好,但可能引入第二个时钟。
  • 当应用时间被模拟而数据库时间是真实时间,或调度器和执行器使用不同机器/时钟时,就会出现问题。
  • 许多人建议把时间传入查询以保持一致性。

第三方库与测试范围

  • 如果你无法控制的代码调用了真实时间,那么注入自己的时钟可能不可能。
  • 建议的应对方式:
    • 使用集成测试,并接受更慢、隔离性更差的检查。
    • 在你自己的接口后面封装该库,并提供测试实现。
    • 关于一方代码的测试是否应当 显式 测试第三方行为,存在争论;这里分歧很大。

系统级时间拦截

  • 像 libfaketime 这样的工具(以及类似的实验性库)会拦截系统调用或 vDSO 函数,在进程级伪造时间。
  • 这被视为强大,但有时也很笨拙(环境变量、LD_PRELOAD),而且对于简单的单元测试来说可能是过度设计。

依赖时间的集成测试

  • 朴素的基于 sleep() 的测试在负载下会慢且不稳定。
  • 常见替代方案:轮询可观察到的副作用,而不是等待固定时长。
  • 其他提议:在进程级使用 faketime、由测试框架可控的租户本地时钟,或者在更高架构层级注入的“测试时钟”。

Mocks、DI 复杂性,以及对文章的批评

  • 有些人担心注入时钟和 mocks 会增加依赖,让代码更难使用;另一些人则认为注入时钟对测试和调试都非常有帮助。
  • 如果 mocks 用于测试公开、定义明确的接口,并且不过度使用,那么是可以接受的。
  • 少数人批评这篇文章过于简单:真正对时间敏感的系统(性能、硬件、多媒体)所面临的挑战,远不止时钟工厂和模板。