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