Машина времени на Java

http://nkoval.info/blog/time-machine-for-java
  • Перевод

В мире существует множество клёвых маленьких библиотек, которые как бы и не знаменитые, но очень полезные. Идея в том, чтобы потихоньку знакомить Хабр с такими вещами под тэгом #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 (которая состоится меньше чем через две недели), чтобы в докладе «На пути к быстрой многопоточной хеш-таблице» рассказать нам о практических подходах к построению высокопроизводительных алгоритмов с использованием всей мощи многоядерных архитектур. На конференции предусмотрены дискуссионные зоны, поэтому после доклада можно будет встретиться с Никитой и обсудить разные вопросы — не только многопоточные хеш-таблицы, но и описанную в статье виртуализацию времени. Билеты можно приобрести на официальном сайте.
  • +52
  • 11,5k
  • 9

JUG.ru Group

621,00

Конференции для взрослых. Java, .NET, JS и др. 18+

Поделиться публикацией
Комментарии 9
    +1
    (doomTime — System.currentTimeMillis()) / millisInDay

    Вообще говоря количество миллисекунд в одном дне разное, так что здесь баг. Можете проверить на достаточно большом интервале.
      +1
      Это всего лишь наглядный пример для иллюстрации, но за интересное наблюдение — спасибо! В частности для нахождения таких багов и нужна библиотека :)
      +1
      Используя простейший подход из примера выше и подменив System.currentTimeMillis(), можно проверить корректность сообщения и только. Но чтобы протестировать корректность расписания, придётся ждать целый день.

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

      Что не умоляет (наверно) возможностей time-test, в тех случаях, когда System.currentTimeMillis() зовется где-то в недрах внешней библиотеки, которую сложно отмокать.
        +2
        А с Thread.sleep(ONE_DAY) что делать будете?
          +1
          Вам нужно протестировать Thread.sleep? или реализацию вашего расписания?
            +1
            Мне нужно не ждать сутки ради этого тестирования :)

            Опять же, вопрос: когда я cмогу быть уверен, что сообщение уже напечаталось или не печатолось вовсе? У time-test для этого есть метод waitUntilThreadsAreFrozen(..).
              +2
              Вот вы уходите от ответа, хотя он очень важен.
              Если вам нужно протестировать поведение Thread.sleep, вопросов нет. Так и надо делать.
              Если вы доверяете Thread.sleep, и вам нужно проверить реализацию вашего класса, в котором каким-то образом обыграны настройки расписания, то вам нужно тестировать параметры, передаваемые в Thread.sleep, и которые генерируются из настроек расписания (т.е. протестировать тот код, который написали лично вы). В этом случае, опять же оборачиваете Thread.sleep в отдельный метод, и мокаете его в тестах. Если не убедил, представьте что вместо Thread.sleep, у вас Files.readAllBytes, или new Random().nextInt.
                +1

                Вы определитесь с тем, что тестировать собираетесь. Если вы хотите протестировать корректность сообщения, то вопросов нет, нужно всего-лишь заменить вызов System.currentTimeMillis() на вызов своей реализации (как — вопрос десятый) и тестировать только метод вывода сообщения, без засыпания на сутки. Однако, если вы хотите протестировать, что сообщение выводится действительно каждый день, то вам придется подождать целый день для проведения теста. При этом ни на какую корректность Thread.sleep() вы полагаться в реальном коде не можете, т.к. в таком случае закладываетесь на реализацию через этот Thread.sleep(), который в любой момент будет заменён Timer-ом или каким-нибудь wait-ом (тут вы вообще ничего не сделаете со своим подходом). Не пытайтесь решать игрушечную проблему, подумайте о реальном коде.


                Вот ещё пример для размышления. У вас есть файл, в который пишутся приходящие события, и который должен быть закрыт, если в течение минуты ничего не приходит. Логично для передачи событий использовать BlockingQueue и делать poll() с таймаутом. И с такой реализацией я не представляю как вы будете тестировать тот факт, что файл через минуту действительно закрывается.

                  0
                  Как вы будете тестировать метод в котором завется new Random().nextInt?

      Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

      Самое читаемое