Как перезапускать упавшие тесты параллельно
Тесты часто бывают нестабильными. Когда тест падает, его можно попробовать перезапустить несколько раз, но перезапуски могут увеличивать время сборки в 2–3 раза. В этой статье мы расскажем, как нам удалось решить эту проблему, а также поделимся инструментом для параллельного перезапуска упавших тестов, который разработали наши инженеры.
В проекте автотестов Wrike находится более 53 000 тестов, которые мы запускаем в 80–150 потоков в зависимости от сборки. При этом часто большую часть времени сборки занимают перезапуски нескольких тестов, которые не используют все потоки. Мы хотим сократить время сборки, потому что во время ее работы мы платим за динамические агенты в TeamCity и динамическое окружение.
Так выглядит пример таймлайна сборки из Allure. Эта сборка потратила 50 из 90 секунд работы на перезапуск одного теста:
Мы хотели уменьшить время перезапуска тестов за счет использования большего количества потоков.
Проблема долгих перезапусков тестов на JUnit 5
В проекте автотестов мы используем Java 17, JUnit 5 и Maven как инструмент сборки, поэтому тесты запускаются через Maven Surefire Plugin.
Раньше мы использовали JUnit 4. Для него Maven Surefire Plugin перезапускает упавшие тесты каждого класса, не дожидаясь окончания первого запуска.
Повторные запуски тестов 1 и 2 не ждут завершения теста 3 в JUnit 4 при условии, что все тесты находятся в разных классах
После перехода на JUnit 5 Maven Surefire Plugin сначала ждет завершение запуска теста 3 и только после этого перезапускает тесты 1 и 2. Это увеличивает время сборки тестов для проекта с большим количеством классов.
Повторные запуски тестов 1 и 2 ждут завершения теста 3 в JUnit 5, даже если тесты находятся в разных классах
С ростом количества модулей в проекте Maven эта проблема стала ощущаться еще острее — каждый модуль ждет, когда запуск тестов завершится, перезапускает тесты, и только после этого запускаются тесты следующего модуля.
Мы частично решили эту проблему с помощью самописного инструмента Merger, который уменьшает время сборки с помощью объединения нескольких Maven модулей в один. Прочитать про него можно в этой статье.
Но даже после внедрения Merger повторные запуски тестов все еще могли занимать большую часть времени сборки (см. повторы теста 3 на изображении выше для случая, когда все тесты находятся в одном модуле).
Нам в голову пришла идея:, а что если перезапускать тесты параллельно и не ждать, пока тест упадет несколько раз подряд? Такой перезапуск будет занимать гораздо меньше времени. Тест будем считать прошедшим, если он прошел хотя бы один раз.
Для параллельных перезапусков таймлайн будет выглядеть так:
Каждый тест перезапустится несколько раз параллельно: это увеличит количество повторов каждого теста, но уменьшит время сборки. Также у нас будет больше статистики для неуспешных тестов, так как теперь они будут запускаться большее количество раз.
Оставался только вопрос, будут ли перезапущенные в параллель тесты иметь такой же процент успеха, как и перезапущенные последовательно. Мы решили реализовать параллельные перезапуски и проверить это.
Готовых решений для параллельных перезапусков мы не нашли. Мы пробовали модифицировать расширение JUnit 5 из junit-pioneer, но оно реализовано через TestTemplate — это значит, что использовать его с другим TestTemplate (например, с параметризованными тестами) мы не сможем (см. issue). По этой же причине не получится модифицировать RepeatedTest — это TestTemplate, который также не работает с параметризованными тестами. JUnit 5 по умолчанию не поддерживает даже последовательные перезапуски.
Мы решили расширить класс JUnitPlatformProvider из Maven Surefire Plugin, который умеет перезапускать тесты последовательно.
Реализация параллельного перезапуска
Во время реализации мы столкнулись с двумя серьезными проблемами:
Allure отчет может пометить тест как упавший, даже если он прошел один раз.
Стандартные механизмы синхронизации JUnit 5 работают только в рамках одного запуска тестов. Это значит, что в параллельном перезапуске не будут правильно работать аннотации @ResourceLock, @Execution и @Isolated.
Исправляем отчет Allure
При параллельном перезапуске может возникнуть ситуация, когда в Allure отчёте тест отметится как упавший, т.к. более ранний перезапуск завершился успехом, а более поздний — провалом.
Это следствие того, что результаты каждого теста отсортированы по времени начала теста:
Мы хотим, чтобы прошедший хотя бы один раз тест помечался как успешный. Для этого необходимо, чтобы все упавшие результаты стартовали раньше успешного.
Логику определения порядка перезапусков поменять нельзя: перезапуски сортируются непосредственно при составлении Allure отчета из файлов результатов. Но в этих файлах можно заменить время начала неуспешных попыток теста на время начала запуска набора тестов. Это решение гарантирует, что при сортировке результатов один из успешных повторов всегда окажется последним.
Allure предоставляет возможность изменять результаты теста через TestLifecycleListener. С его помощью мы изменяем время начала всех не прошедших попыток в параллельном запуске.
Время реального запуска теста при необходимости можно записать в отдельный Allure Label.
После этих изменений прошедший хотя бы один раз тест будет помечаться как прошедший. Все успешные и неуспешные попытки прогона (кроме последней) будут записаны в перезапуски теста в Allure.
В реальном масштабе тесты стартуют почти одновременно, и сдвиг на несколько миллисекунд не будет заметен на таймлайне. Ниже приведен пример параллельного перезапуска одного теста без сдвига времени начала.
Сдвиг времени начала занимает десятые доли пикселя и визуально не влияет на таймлайн
Проблема поддержки механизмов синхронизации JUnit 5
В рамках одного запуска тест может выполниться только один раз. Поэтому для параллельного перезапуска приходится делать несколько запусков в параллель. Также все параллельные запуски могут не поместиться в отведенное количество потоков, поэтому необходимо разделить тесты на разные запуски.
На картинке видно, как могут перезапускаться тесты при 6 отведенных потоках.
JUnit 5 производит синхронизацию тестов только в рамках одного запуска, поэтому параллельные перезапуски игнорируют аннотации JUnit 5 для синхронизации: @ResourceLock, @Execution и @Isolated.
В примере выше все повторы теста 1 будут исполнены параллельно, даже если класс или тест имеют аннотации @ResourceLock, @Execution или @Isolated.
Мы не используем методы синхронизации JUnit 5 в тестах, потому что наши тесты полностью независимы друг от друга. Если вы используете методы синхронизации из JUnit 5, то вам придется доработать логику перезапуска, чтобы запускать такие тесты отдельно.
Условия, при которых тесты перезапускаются параллельно
Мы отказались от идеи всегда перезапускать тесты в параллель.
Мы перезапускаем тесты в параллель, если:
Все попытки перезапуска «помещаются» в отведенное количество потоков. Если количество оставшихся перезапусков * количество падений <= количество потоков, используемое для запуска тестов.
Тесты осталось перезапустить более одного раза. Иначе параллельный перезапуск ничем не будет отличаться от последовательного.
Если условия из списка выше не соблюдены, мы делаем один последовательный перезапуск и проверяем условия заново, пока все упавшие тесты не будут перезапущены заданное количество раз.
Ниже приведена блок-схема алгоритма запуска и перезапуска тестов.
Результаты внедрения параллельных перезапусков
После внедрения параллельных перезапусков мы добились ускорения сборок примерно на 10%, при росте количества тестов в сборках на 26%.
Но такое ускорение не далось нам «бесплатно». Мы собрали статистику по 10 млн запущенных тестов до и после внедрения параллельных перезапусков и вычислили эффективность разных перезапусков для нашего проекта и инфраструктуры:
Тип перезапуска | Доля успешно перезапущенных тестов ↑ |
4 последовательных | 48.9% |
3 последовательных | 47.8% |
4 параллельных | 47.2% |
2 последовательных | 46.1% |
1 последовательный | 42% |
Из таблицы можно сделать следующие выводы:
4 параллельных перезапуска успешно перезапускают тесты на 5.2% по сравнению с одним перезапуском, но занимают такое же количество времени.
4 параллельных перезапуска успешно перезапускают на 1.7% меньше тестов, чем 4 последовательных перезапуска, но работают гораздо быстрее.
Разберем на примере, что значит доля перезапущенных тестов. Мы запустили 10 тестов, 6 из них упали. После перезапуска 3 из 6 упавших тестов прошли. Это значит, что доля успешно перезапущенных тестов равна 50% (3 / 6 = 50%). Эта метрика показывает, насколько перезапуски помогают тестам проходить успешно.
Таймлайн, на котором объясняется, что такое доля успешно перезапущенных тестов
Основная цель параллельных перезапусков — ускорение сборки с тестами. Мы добились ускорения, но в первое время доля успешных перезапусков тестов сильно упала. Это случилось в том числе из-за того, что мы стали нагружать инфраструктуру больше, чем раньше. Мы нашли узкое место, исправили ошибку и параллельные перезапуски стали более успешными.
Почему параллельные запуски оказались не такими успешными:
Инфраструктура не выдерживает такое количество параллельных перезапусков.
Временная недоступность сервисов негативно сказывается на успешности параллельных перезапусков, так как все попытки запускаются одновременно.
Мы продолжаем настраивать параллельные перезапуски и улучшать инфраструктуру для тестов. Однако вопрос о том, нужно ли перезапускать тесты быстрее, если их успешность падает, остается открытым.
Из-за того, что количество автотестов в нашем проекте постоянно растет, мы часто сталкиваемся с новыми проблемами. Иногда решения оказываются успешными, иногда компромиссными, но всегда приводят к более глубокому пониманию инфраструктуры и улучшениям проекта.
Вы можете попробовать параллельные перезапуски, скачав исходный код с Wrike Github. Мы продолжаем работать над параллельными перезапусками и будем рады вашим комментариям и предложениям! Обратите внимание: у кода есть ограничения, и он может подойти не для всех проектов.