伪系统时钟是一种设计模式,用于解决严重依赖系统时间的程序的可测试性问题。 如果业务逻辑流取决于当前系统时间,那么测试各种流就变得很麻烦甚至是不可能的。 此类问题场景的示例包括:
- 某些业务流程仅在周末运行(或被忽略)
- 自某些其他事件发生后一个小时后才触发某些逻辑
- 当两个事件恰好同时发生(通常为1 ms精度)时,应该发生一些情况
- …
上面的每种情况都会带来一系列独特的挑战。 从字面上看,我们的单元测试只能在特定的一天(1)运行或睡眠一个小时才能观察到某些行为。 在某些情况下,方案(3)甚至可能无法测试,因为系统时钟随时都可能滴答1毫秒,因此使测试不可靠。
伪系统时钟通过在简单接口上抽象系统时间来解决这些问题。 本质上你从不打电话
new Date()
, new GregorianCalendar()
或System.currentTimeMillis()
但始终依赖于此:
import org.joda.time.{DateTime, Instant}
trait Clock {
def now(): Instant
def dateNow(): DateTime
}
如您所见,我依赖于Joda Time库。 由于我们已经在Scala土地上,因此不妨考虑一下
标量时间或nscala时间包装器。 此外,抽象名称Clock
不是巧合。 它简短而具有描述性,但更重要的是,它模仿Java 8中的java.time.Clock
类-恰好解决了在JDK级别上讨论的同一问题! 但是,由于Java 8仍然不在这里,让我们保留我们甜美而小巧的抽象。
通常使用的标准实现只是将时间委托给系统时间:
import org.joda.time.{Instant, DateTime}
object SystemClock extends Clock {
def now() = Instant.now()
def dateNow() = DateTime.now()
}
为了进行单元测试,我们将开发其他实现,但首先让我们关注使用场景。 在典型的Spring / JavaEE应用程序中,伪造的系统时钟可以变成容器可以轻松注入的依赖项。 这使得对系统时间的依赖变得明确且易于管理,尤其是在测试中:
@Controller
class FooController @Autowired() (fooService: FooService, clock: Clock) {
def postFoo(name: String) =
fooService store new Foo(name, clock)
}
在这里,我使用Spring构造函数注入,要求容器提供一些Clock
实现。 当然,在这种情况下, SystemClock
被标记为@Service
。 在单元测试中,我可以通过假的实现,而在集成测试中,我可以在上下文中放置另一个@Primary
bean,以遮盖SystemClock
。
这很好用,但是对于某些类型的对象(即实体/ DTO bean和实用程序( static
)类)却很痛苦。 这些通常不是由Spring管理的,因此它不能向它们注入Clock
Bean。 这迫使我们通过
Clock
从最后一个“管理”层手动:
class Foo(fooName: String, clock: Clock) {
val name = fooName
val time = clock.dateNow()
}
类似地:
object TimeUtil {
def firstFridayOfNextMonth(clock: Clock) = //...
}
从设计角度来看,这还不错。 Foo
构造函数和firstFridayOfNextMonth()
方法都依赖于系统时间,因此我们将其明确化。 另一方面,必须将Clock
依赖项拖到某些层上,有时要拖到很多层,以便可以在某处以一种方法使用它。 同样,这本身还不错。 如果您的高级方法具有Clock
参数,则从一开始就知道它依赖于当前时间。 但是似乎仍然有很多样板和开销,却几乎没有收益。 幸运的是Scala可以在以下方面为我们提供帮助:
让我们重构一下解决方案,使Clock
是一个隐式参数:
@Controller
class FooController(fooService: FooService) {
def postFoo(name: String)(implicit clock: Clock) =
fooService store new Foo(name)
}
@Service
class FooService(fooRepository: FooRepository) {
def store(foo: Foo)(implicit clock: Clock) =
fooRepository storeInFuture foo
}
@Repository
class FooRepository {
def storeInFuture(foo: Foo)(implicit clock: Clock) = {
val friday = TimeUtil.firstFridayOfNextMonth()
//...
}
}
object TimeUtil {
def firstFridayOfNextMonth()(implicit clock: Clock) = //...
}
注意,我们如何忽略第二个clock
参数来调用fooRepository storeInFuture foo
。 然而,仅此还不够。 我们仍然必须提供一些Clock
实例作为第二个参数,否则会出现编译错误:
could not find implicit value for parameter clock: com.blogspot.nurkiewicz.foo.Clock
controller.postFoo("Abc")
^
not enough arguments for method postFoo: (implicit clock: com.blogspot.nurkiewicz.foo.Clock)Unit.
Unspecified value parameter clock.
controller.postFoo("Abc")
^
编译器试图找到Clock
参数的隐式值,但失败了。 但是我们真的很接近,最简单的解决方案是使用package对象 :
package com.blogspot.nurkiewicz.foo
package object foo {
implicit val clock = SystemClock
}
前面定义SystemClock
。 这是发生的情况:每次我调用带有implicit clock: Clock
的函数implicit clock: Clock
com.blogspot.nurkiewicz.foo
包中的implicit clock: Clock
参数时,编译器将发现foo.clock
隐式变量并将其透明地传递。 换句话说,以下代码段是等效的,但第二个代码段提供了显式Clock
,因此忽略了隐式代码:
TimeUtil.firstFridayOfNextMonth()
TimeUtil.firstFridayOfNextMonth()(SystemClock)
也等效(编译器将第一种形式转换为第二种形式):
fooService.store(foo)
fooService.store(foo)(SystemClock)
有趣的是,在字节码级别上,隐式参数与普通参数没有什么不同,因此,如果要从Java调用此类方法,则传递Clock
实例是强制性和显式的。
implicit clock
参数似乎工作得很好。 它隐藏了普遍存在的依赖关系,同时仍然提供了覆盖它的可能性。 例如:
测验
抽象系统时间的全部目的是通过完全控制时间流来进行单元测试。 让我们从一个简单的假系统时钟实现开始,该实现始终返回相同的指定时间:
class FakeClock(fixed: DateTime) extends Clock {
def now() = fixed.toInstant
def dateNow() = fixed
}
当然,您可以随意放置任何逻辑:通过任意值提前时间,加快时间等。您明白了。 现在请记住, implicit
参数的原因是在正常生产代码中隐藏Clock
,同时仍然能够提供替代实现。 有两种方法:在测试中显式地传递FakeClock
:
val fakeClock = new FakeClock(
new DateTime(2013, 7, 15, 0, 0, DateTimeZone.UTC))
controller.postFoo("Abc")(fakeClock)
或使其隐式但更特定于编译器解析机制:
implicit val fakeClock = new FakeClock(
new DateTime(2013, 7, 15, 0, 0, DateTimeZone.UTC))
controller.postFoo("Abc")
后一种方法更易于维护,因为您不必始终记住将fakeClock
传递给fakeClock
方法。 当然, fakeClock
可以更全局地定义为字段,甚至可以在测试包对象内部定义。 无论我们选择哪种技术来提供fakeClock
,都会在对服务,存储库和实用程序的所有调用中使用它。 当我们为该参数赋予显式值时,隐式参数解析将被忽略。
问题与总结
以上针对严重依赖时间的测试系统的解决方案本身并非没有问题。 首先隐式
Clock
参数必须在所有层中传播直至客户端代码。 注意,仅在存储库/实用程序层中需要Clock
,而我们必须将它向上拖动到控制器层。 没什么大不了的,因为编译器会为我们填充它,但是我们的大多数方法迟早都会包含这个额外的参数。
同样,在代码之上运行的Java和框架也不知道在编译时会发生Scala隐式解析。 因此,例如我们的Spring MVC控制器将无法工作,因为Spring不知道SystemClock
隐式变量。 尽管可以使用WebArgumentResolver
。
伪系统时钟模式通常仅在一致使用时才起作用。 如果您直接使用实时而不是Clock
抽象时,甚至只有一个地方,那么在寻找测试失败原因时会很幸运。 这同样适用于库和SQL查询。 因此,如果您要设计一个依赖当前时间的库,请考虑提供可插入的Clock
抽象,以便客户端代码可以提供自定义实现,例如FakeClock
。 另一方面,在SQL中,不要依赖像NOW()
这样的函数,而总是显式地从代码中提供日期(因此从自定义Clock
提供日期)。