Статьи

Поддельные системные часы в Scala с неявными параметрами

Поддельные системные часы — это шаблон проектирования, решающий проблемы тестируемости программ, сильно зависящих от системного времени. Если поток бизнес-логики зависит от текущего системного времени, тестирование различных потоков становится громоздким или даже невозможным. Примеры таких проблемных сценариев включают в себя:

  1. определенный бизнес-поток работает только (или игнорируется) в выходные дни
  2. некоторая логика срабатывает только через час после какого-то другого события
  3. когда два события происходят в одно и то же время (обычно с точностью до 1 мс), что-то должно произойти

Каждый сценарий выше создает уникальный набор проблем. В буквальном смысле наши юнит-тесты должны были бы выполняться только в определенный день (1) или спать в течение часа, чтобы наблюдать какое-то поведение. Сценарий (3) может даже оказаться невозможным для тестирования при некоторых обстоятельствах, поскольку системные часы могут тикать 1 миллисекунду в любое время, что делает тест ненадежным.

Поддельные системные часы решают эти проблемы, абстрагируя системное время от простого интерфейса. По сути ты никогда не звонишь
new Date() , new GregorianCalendar() или System.currentTimeMillis() но всегда полагайтесь на это:

1
2
3
4
5
6
7
8
9
import org.joda.time.{DateTime, Instant}
 
trait Clock {
 
    def now(): Instant
 
    def dateNow(): DateTime
 
}

Как вы можете видеть, я зависим от библиотеки Joda Time . Поскольку мы уже на земле Скала, можно подумать
scala-time или nscala-time обертки. Более того, абстрактное название Clock не случайно. Он короткий и описательный, но, что более важно, он имитирует класс java.time.Clock из Java 8 — это решение той же проблемы, которая обсуждалась здесь на уровне JDK! Но поскольку Java 8 все еще не здесь, давайте останемся с нашей милой и маленькой абстракцией.

Стандартная реализация, которую вы обычно используете, просто делегирует системное время:

1
2
3
4
5
6
7
8
9
import org.joda.time.{Instant, DateTime}
 
object SystemClock extends Clock {
 
    def now() = Instant.now()
 
    def dateNow() = DateTime.now()
 
}

Для целей модульного тестирования мы разработаем другие реализации, но сначала давайте сосредоточимся на сценариях использования. В типичных приложениях Spring / JavaEE поддельные системные часы можно превратить в зависимость, которую контейнер может легко внедрить. Это делает зависимость от системного времени явной и управляемой, особенно в тестах:

1
2
3
4
5
6
7
@Controller
class FooController @Autowired() (fooService: FooService, clock: Clock) {
 
    def postFoo(name: String) =
        fooService store new Foo(name, clock)
 
}

Здесь я использую инъекцию конструктора Spring, запрашивая у контейнера некоторую реализацию Clock . Конечно, в этом случае SystemClock помечается как @Service . В модульных тестах я могу пройти поддельную реализацию, а в интеграционных тестах я могу поместить в контекст другой компонент @Primary , @Primary SystemClock .

Это прекрасно работает, но становится болезненным для определенных типов объектов, а именно для компонентов / DTO-компонентов и служебных ( static ) классов. Spring обычно не управляет ими, поэтому он не может вводить им bean-компонент Clock . Это заставляет нас пройти
Clock вручную из последнего «управляемого» слоя:

1
2
3
4
5
6
class Foo(fooName: String, clock: Clock) {
 
    val name = fooName
    val time = clock.dateNow()
 
}

так же:

1
2
3
4
5
object TimeUtil {
 
    def firstFridayOfNextMonth(clock: Clock) = //...
 
}

Это не плохо с точки зрения дизайна. И конструктор Foo и firstFridayOfNextMonth() полагаются на системное время, поэтому давайте сделаем его явным. С другой стороны, зависимость от Clock необходимо перетаскивать, иногда через множество слоев, просто так, чтобы ее можно было где-то использовать в одном методе. Опять же, это неплохо само по себе . Если ваш метод высокого уровня имеет параметр Clock вы с самого начала знаете, что он зависит от текущего времени. Но все же, похоже, много шаблонов и накладных расходов за небольшую выгоду. К счастью, Scala может помочь нам с:

implicit параметры

Давайте немного изменим наше решение, чтобы Clock был неявным параметром:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
@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) = //...
 
}

Обратите внимание, как мы вызываем fooRepository storeInFuture foo игнорируя второй параметр clock . Однако одного этого недостаточно. Мы все еще должны предоставить некоторый экземпляр Clock качестве второго параметра, в противном случае произойдет ошибка компиляции:

1
2
3
4
5
6
7
8
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 но не смог. Как бы мы ни были близки, самое простое решение — использовать объект пакета :

1
2
3
4
5
6
7
package com.blogspot.nurkiewicz.foo
 
package object foo {
 
    implicit val clock = SystemClock
 
}

Где SystemClock был определен ранее. Вот что происходит: каждый раз, когда я вызываю функцию с implicit clock: Clock в пакете com.blogspot.nurkiewicz.foo , компилятор обнаруживает неявную переменную foo.clock и передает ее прозрачно. Другими словами, следующие фрагменты кода эквивалентны, но второй предоставляет явные Clock , игнорируя при этом:

1
2
TimeUtil.firstFridayOfNextMonth()
TimeUtil.firstFridayOfNextMonth()(SystemClock)

также эквивалентно (первая форма превращается во вторую компилятором):

1
2
fooService.store(foo)
fooService.store(foo)(SystemClock)

Интересно, что на уровне байт-кода неявные параметры ничем не отличаются от обычных параметров, поэтому, если вы хотите вызвать такой метод из Java, передача экземпляра Clock является обязательной и явной.

implicit clock параметр implicit clock кажется, работает довольно хорошо. Он скрывает повсеместную зависимость, но в то же время дает возможность ее переопределить. Например в:

тесты

Весь смысл абстрагирования системного времени состоял в том, чтобы включить модульное тестирование, получив полный контроль над течением времени. Давайте начнем с простой реализации поддельных системных часов, которая всегда возвращает одно и то же указанное время:

1
2
3
4
5
class FakeClock(fixed: DateTime) extends Clock {
    def now() = fixed.toInstant
 
    def dateNow() = fixed
}

Конечно, вы можете использовать любую логику: увеличивать время на произвольную величину, ускорять его и т. Д. Вы поняли идею. Теперь помните, что причина implicit параметра заключалась в том, чтобы скрыть Clock от обычного производственного кода, в то же время предоставляя альтернативную реализацию. Есть два подхода: либо передать FakeClock явно в тестах:

1
2
3
4
val fakeClock = new FakeClock(
   new DateTime(2013, 7, 15, 0, 0, DateTimeZone.UTC))
 
controller.postFoo("Abc")(fakeClock)

или сделайте это неявным, но более конкретным для механизма разрешения компилятора:

1
2
3
4
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 ).