В мире существует множество клёвых маленьких библиотек, которые как бы и не знаменитые, но очень полезные. Идея в том, чтобы потихоньку знакомить Хабр с такими вещами под тэгом #javalifehacker. Сегодня речь пойдёт о time-test, в котором всего 16 коммитов, но их хватает. Автор библиотеки — Никита Коваль, и это перевод его статьи, изначально написанной для блога Devexperts.
Бывает непросто написать юнит-тесты для завязанной на работу со временем функциональности. Иногда можно взять метод, возвращающий время, и заменить его реализацию на тестовый код. Но для тестирования реальных приложений этого недостаточно. Давайте разберёмся, почему такое решение может не сработать и что в действительности нужно для тестирования времени.
Вот простейший метод, считающий количество дней до конца света:
fun daysBeforeDoom() {
return doomTime - System.currentTimeMillis()) / millisInDay
}
Скорее всего, для его тестирования достаточно простой подмены всех вызовов System.currentTimeMillis()
с помощью существующих инструментов (раз, два) или написания трансформации кода на ASM или AspectJ (если нужно какое-то специализированное поведение).
Но существуют случаи, когда этого подхода недостаточно. Представьте, что мы пишем будильник, который будит каждый день и отображает сообщение: «Осталось <N> дней до конца света»:
while (true) {
Thread.sleep(ONE_DAY)
println("${daysBeforeDoom()} days left till the doomsday")
}
Но как протестировать этот код? Как проверить, что он действительно выполняется каждый день и выводит правильное сообщение? Используя простейший подход из примера выше и подменив System.currentTimeMillis()
, можно проверить корректность сообщения и только. Но чтобы протестировать корректность расписания, придётся ждать целый день.
Таким образом, практически невозможно тестировать подобный код без использования дополнительных инструментов. Так давайте их напишем!
Итак, имеется два метода, которые возвращают время: System.currentTimeMillis()
и System.nanoTime()
. Кроме того, имеется несколько синхронизирующих методов с возможностью указать максимальное время ожидания: Thread.sleep(..)
, Object.wait(..)
и LockSupport.park(..)
.
Чтобы управлять временем, хочется сделать какой-то метод increaseTime(..)
, который изменяет виртуальное время и будит необходимые потоки.
Достичь этого можно, если все работающие со временем методы заменить на тестовые реализации. Давайте взглянем, как это могло бы работать.
Пример теста:
increaseTime(ONE_DAY)
checkMessage()
Вы, наверное, уже заметили, что этот тест создаёт потенциальную возможность гонок между проверкой сообщения и операцией печати, которая не выполняется моментально. Конечно, можно попробовать добавить паузу.
increaseTime(ONE_DAY)
Thread.sleep(500 /*ms*/)
checkMessage()
В обычной жизни этот тест почти всегда будет работать, но нет никаких настоящих гарантий, что checkMessage()
не вызовется раньше, чем отобразится сообщение. Это может случиться в результате нарастания сложности логики тестирования или просто при запуске кода на перегруженном сервере. Тут может возникнуть желание увеличить таймаут, но это решение только замедлит тесты, а гарантий корректности всё так же не будет.
Вместо этого нам нужен специальный метод, который ждёт, пока все проснувшиеся треды не сделают своё дело.
В идеале хотелось бы написать такой тест:
increaseTime(ONE_DAY)
waitUntilThreadsAreFrozen(1_000/*ms, timeout*/)
checkMessage()
Таким образом, нам нужно поддержать не только виртуализацию зависящих от времени методов, но и метод waitUntilThreadsAreFrozen
, что сделать одновременно непросто.
Работая в Devexperts, Никита написал инструмент под названием time-test, который решает эту задачу. Давайте посмотрим, как он работает.
Time-test написан в виде Java-агента. Чтобы использовать его, нужно добавить параметр -javaagent:timetest.jar
и положить его в classpath. Этот инструмент трансформирует байткод и заменяет все работающие со временем методы на вызовы своих реализаций. Написание хорошего java agent — зачастую непростая задача, поэтому Никита разработал фреймворк JAgent, который упрощает это дело.
При создании тестов нужно включить TestTimeProvider
. Он реализует все необходимые методы (включая System.currentTimeMillis()
, Thread.sleep(..)
, Object.wait(..)
, LockSupport.park(..)
и т.п.) и перекрывает их обычную реализацию. В большинстве тестов нет никакой нужды в прямом управлении временем, используемым в нижележащей реализации. Поэтому, пока вы не подключили TestTimeProvider, инструмент продолжает использовать дефолтные релизации вышеперечисленных методов, оборачивая их своим кодом. После же подключения TestTimeProvider
появляется возможность использовать методы TestTimeProvider.setTime(..)
, TestTimeProvider.increaseTime(..)
и TestTimeProvider.waitUntilThreadsAreFrozen(..)
.
TimeProvider.java
:
long timeMillis();
long nanoTime();
void sleep(long millis) throws InterruptedException;
void sleep(long millis, int nanos) throws InterruptedException;
void waitOn(Object monitor, long millis) throws InterruptedException;
void waitOn(Object monitor, long millis, int nanos) throws InterruptedException;
void notifyAll(Object monitor);
void notify(Object monitor);
void park(boolean isAbsolute, long time);
void unpark(Object thread);
Как было написано выше, основная проблема реализации TestTimeProvider
— одновременная поддержка и методов по работе со временем, и waitUntilThreadsAreFrozen(..)
. Поэтому на каждое изменение времени все нужные треды вначале помечаются как работающие, и только потом будятся. Одновременно с этим waitUntilThreadsAreFrozen(..)
ждёт, пока все треды не окажутся в состоянии ожидания, чтобы ни один из них не был помечен как работающий. В рамках этого подхода треды просыпаются, сбрасывают свою отметку, выполняют задачу и возвращаются в состояние ожидания — и только тогда waitUntilThreadsAreFrozen(..)
поймёт, что они отработали.
Как выглядит тест с использованием TestTimeProvider
:
@Before
public void setup() {
// Use TestTimeProvider for this test
TestTimeProvider.start(/* initial time could be passed here */);
}
@After
public void reset() {
// Reset time provider to default after the test execution
TestTimeProvider.reset();
}
@Test
public void test() {
runMyConcurrentApplication();
TestTimeProvider.increaseTime(60_000 /*ms*/);
TestTimeProvider.waitUntilThreadsAreFrozen(1_000 /*ms*/);
checkMyApplicationState();
}
Есть еще одна сложность с виртуализацией времени. Описанный подход хорошо работает, если нужно контролировать время во всей JVM целиком. Но ведь обычно хочется не затронуть своим вмешательством библиотеку для тестирования (типа JUnit), тред сборщика мусора и другие вещи, напрямую не относящиеся к тестируемому фрагменту кода. Поэтому обязательно нужно определять, выполняемся ли мы в тестируемом коде и стоит ли нам, исходя из этого, виртуализировать время. Для этого time-test должен знать входные точки тестируемого кода (обычно это классы тестов). Затем time-test начинает отслеживать запуски новых тредов и помечать их как «свои», что означает, что для них будет применять виртуализация времени. Однако могут возникнуть проблемы, если используется ForkJoinPool
, поскольку он запускается не из тестового кода, и time-test не может понять, что необходимо виртуализировать время и там. Чтобы работать и с похожими на ForkJoinPool конструкциями, нужно расширить определение входных точек.
Думаю, теперь стало понятно, что тестирование работающей со временем функциональности может оказаться не такой уж простой задачей. Надеюсь, time-test облегчит вам жизнь, исходники можно забрать на GitHub.
Об авторе
Никита Коваль — инженер-исследователь в исследовательской группе dxLab компании Devexperts. Помимо этого, он студент кафедры Компьютерных Технологий в ИТМО, где к тому же преподает курс по многопоточному программированию. Главным образом интересуется многопоточными алгоритмами, верификацией программ и их анализом.
Минутка рекламы. Никита приедет на конференцию JBreak 2018 (которая состоится меньше чем через две недели), чтобы в докладе «На пути к быстрой многопоточной хеш-таблице» рассказать нам о практических подходах к построению высокопроизводительных алгоритмов с использованием всей мощи многоядерных архитектур. На конференции предусмотрены дискуссионные зоны, поэтому после доклада можно будет встретиться с Никитой и обсудить разные вопросы — не только многопоточные хеш-таблицы, но и описанную в статье виртуализацию времени. Билеты можно приобрести на официальном сайте.
Комментарии (7)
ggo
21.02.2018 10:34Используя простейший подход из примера выше и подменив System.currentTimeMillis(), можно проверить корректность сообщения и только. Но чтобы протестировать корректность расписания, придётся ждать целый день.
Посыл правильный, реализация куда-то в сторону уехала.
Верно отмечено, тестировать нужно корректность расписания. Оборачиваем System.currentTimeMillis() в отдельный метод в нашем классе, и мокаем его (наш метод) в тестах с любой логикой, которой нужно для тестов.
Что не умоляет (наверно) возможностей time-test, в тех случаях, когда System.currentTimeMillis() зовется где-то в недрах внешней библиотеки, которую сложно отмокать.ndkoval
21.02.2018 11:35А с
Thread.sleep(ONE_DAY)
что делать будете?ggo
21.02.2018 12:07+1Вам нужно протестировать Thread.sleep? или реализацию вашего расписания?
ndkoval
21.02.2018 12:15+1Мне нужно не ждать сутки ради этого тестирования :)
Опять же, вопрос: когда я cмогу быть уверен, что сообщение уже напечаталось или не печатолось вовсе? У time-test для этого есть методwaitUntilThreadsAreFrozen(..)
.ggo
22.02.2018 10:21Вот вы уходите от ответа, хотя он очень важен.
Если вам нужно протестировать поведение Thread.sleep, вопросов нет. Так и надо делать.
Если вы доверяете Thread.sleep, и вам нужно проверить реализацию вашего класса, в котором каким-то образом обыграны настройки расписания, то вам нужно тестировать параметры, передаваемые в Thread.sleep, и которые генерируются из настроек расписания (т.е. протестировать тот код, который написали лично вы). В этом случае, опять же оборачиваете Thread.sleep в отдельный метод, и мокаете его в тестах. Если не убедил, представьте что вместо Thread.sleep, у вас Files.readAllBytes, или new Random().nextInt.
Valle
Вообще говоря количество миллисекунд в одном дне разное, так что здесь баг. Можете проверить на достаточно большом интервале.
ndkoval
Это всего лишь наглядный пример для иллюстрации, но за интересное наблюдение — спасибо! В частности для нахождения таких багов и нужна библиотека :)