В мире существует множество клёвых маленьких библиотек, которые как бы и не знаменитые, но очень полезные. Идея в том, чтобы потихоньку знакомить Хабр с такими вещами под тэгом #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)


  1. Valle
    20.02.2018 19:40
    +1

    (doomTime — System.currentTimeMillis()) / millisInDay

    Вообще говоря количество миллисекунд в одном дне разное, так что здесь баг. Можете проверить на достаточно большом интервале.


    1. ndkoval
      20.02.2018 19:53
      +2

      Это всего лишь наглядный пример для иллюстрации, но за интересное наблюдение — спасибо! В частности для нахождения таких багов и нужна библиотека :)


  1. ggo
    21.02.2018 10:34

    Используя простейший подход из примера выше и подменив System.currentTimeMillis(), можно проверить корректность сообщения и только. Но чтобы протестировать корректность расписания, придётся ждать целый день.

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

    Что не умоляет (наверно) возможностей time-test, в тех случаях, когда System.currentTimeMillis() зовется где-то в недрах внешней библиотеки, которую сложно отмокать.


    1. ndkoval
      21.02.2018 11:35

      А с Thread.sleep(ONE_DAY) что делать будете?


      1. ggo
        21.02.2018 12:07
        +1

        Вам нужно протестировать Thread.sleep? или реализацию вашего расписания?


        1. ndkoval
          21.02.2018 12:15
          +1

          Мне нужно не ждать сутки ради этого тестирования :)

          Опять же, вопрос: когда я cмогу быть уверен, что сообщение уже напечаталось или не печатолось вовсе? У time-test для этого есть метод waitUntilThreadsAreFrozen(..).


          1. ggo
            22.02.2018 10:21

            Вот вы уходите от ответа, хотя он очень важен.
            Если вам нужно протестировать поведение Thread.sleep, вопросов нет. Так и надо делать.
            Если вы доверяете Thread.sleep, и вам нужно проверить реализацию вашего класса, в котором каким-то образом обыграны настройки расписания, то вам нужно тестировать параметры, передаваемые в Thread.sleep, и которые генерируются из настроек расписания (т.е. протестировать тот код, который написали лично вы). В этом случае, опять же оборачиваете Thread.sleep в отдельный метод, и мокаете его в тестах. Если не убедил, представьте что вместо Thread.sleep, у вас Files.readAllBytes, или new Random().nextInt.