Как стать автором
Обновить

Избавляемся от Flaky тестов в CI/CD при помощи JMina

Уровень сложностиПростой
Время на прочтение4 мин
Количество просмотров1.1K

Представьте: вы написали код, покрыли его тестами, запустили их локально — тесты успешно прошли. Вы загрузили изменения в репозиторий, пайплайн успешно завершился. Самое время расслабиться и приступить к новым задачам. Но не тут-то было!

Спустя некоторое время в CI/CD падает тест. Вы запускаете тесты локально — они проходят успешно. Вы снова запускаете пайплайн в CI/CD — и тесты снова проходят. Однако через какое-то время ситуация повторяется.

Знакомая картина? Значит, вы уже сталкивались с flaky-тестами.

Flaky-тесты — это тесты, которые могут как успешно пройти, так и упасть без каких-либо изменений в коде.

По работе я довольно часто сталкиваюсь с flaky-тестами. Иногда удаётся поймать падение при локальном прогоне тестов. Иногда причина становится ясной по полученной ошибке. Иногда мы можем проанализировать логи и найти источник проблемы.

Но бывает, что тесты падают только в CI/CD: ошибка ничего не объясняет, а логи превышают лимиты и не сохраняются полностью. Такая ситуация особенно часто возникает при написании интеграционных тестов для сложных многошаговых процессов, работающих с большим объёмом данных.

«Ах, если бы можно было вставить assert прямо в код, чтобы ловить ошибки там, где они возникают», — подумал я. В принципе, мы иногда действительно добавляем проверки (assertions) в сам код — например, для проверки параметров методов на null.

Но, конечно, мы не знаем заранее, какое конкретное значение должна иметь переменная в конкретный момент времени. Кроме того, добавление множества assertions сделало бы код практически нечитаемым.

Тогда мне пришла в голову идея: а что если сделать небольшую утилиту, которая позволяла бы вставлять в код лямбду из теста? Как-бы, заминировать код. Это могло бы выглядеть примерно так:

В коде:

inlineAssert("step1", i, status);

В тесте:

on("step1").check((int i, Status status) -> {
	// проверки
});
myClass.myMethod();

Конечно, для этого придётся добавить в код несколько дополнительных строк, которые сами по себе никак не будут влиять на выполнение программы — они нужны будут только для тестов. Но ничего страшного: мы ведь уже добавляем в код логи!

Минуточку...

Для логирования мы используем SLF4J, который сам по себе ничего не логирует, а делегирует вызовы методов, например, в Logback. Точно так же он может делегировать вызовы любому другому провайдеру — например, нашей утилите, в которой мы сможем выполнять необходимые проверки.

Это примерно аналогично тому, как если бы мы использовали Mockito, чтобы создать Spy для логгера.

Так у меня и родилась идея утилиты JMina.

Возможности JMina:

  • Перехватывать вызовы методов логирования SLF4J по имени логгера, уровню логирования, маркеру и сообщению — в любых комбинациях.

  • Проверять передаваемые параметры на равенство заданным значениям.

  • Использовать лямбду для проверки параметров.

  • Вызывать исключения при выполнении определённых логов.

  • Проверять, что все ожидаемые логи были вызваны.

Рассмотрим, как это работает на простейшем примере — вычислении корней квадратного уравнения.

Алгоритм состоит из двух шагов: сначала вычисляется дискриминант, затем, на основе дискриминанта, находятся сами корни. Ошибка может произойти на любом из этих шагов. Чтобы понять, на каком именно этапе возникла проблема, добавим в код логирование значения дискриминанта.

public class QuadraticEquation {
    private final Logger log = LoggerFactory.getLogger(QuadraticEquation.class);

    public List<Double> solve(double a, double b, double c) {
        double discriminant = b * b - 4 * a * c;

        log.debug("discriminant: {}", discriminant);    // Log the discriminant value to verify it during test execution

        if (discriminant < 0) {
            return Collections.emptyList();
        } else {
            List<Double> roots = new ArrayList<>();

            if (discriminant > 0) {
                roots.add((-b - sqrt(discriminant)) / (2 * a));
                roots.add((-b + sqrt(discriminant)) / (2 * a));
            } else {
                roots.add(-b / (2 * a));
            }

            return roots;
        }
    }
}

А в тесте добавим проверку его значения.

public class QuadraticEquationTest {
    @Test
    public void testSolve() {
        // Verify discriminant value inside the solve method
        Mina
                .on(QuadraticEquation.class, DEBUG, "discriminant: {}")
                .check((Double discriminant) -> assertEquals(9, discriminant));

        // Run our code
        List<Double> roots = new QuadraticEquation().solve(1, -1, -2);

        // Verify that all logs were called
        Mina.assertAllCalled();

        // Verify roots
        assertEquals(-1, roots.get(0));
        assertEquals(2, roots.get(1));
    }

    @AfterEach
    public void clean() {
        // Don't forget to clean-up context after each test
        Mina.clean();
    }
}

Теперь, если на этапе вычисления дискриминанта будет получено неправильное значение, тест упадёт с ошибкой. Причём stack trace укажет именно на то место в коде, где произошла ошибка. Таким образом мы сможем быстро найти проблему и исправить её.

На что ещё способна JMina?

Если мы тестируем успешный сценарий (success path), то, скорее всего, не ожидаем появления сообщений об ошибках. И если такое сообщение будет залогировано, мы можем сразу остановить выполнение теста и выбросить исключение.

Mina.on(ERROR).exception();

Flaky-тесты могут отнимать массу времени и сил, особенно когда ошибки проявляются только в CI/CD, а логи оказываются неполными или бесполезными. Чтобы упростить отладку и повысить надёжность тестов, я разработал утилиту JMina — лёгкое, но мощное решение для перехвата и проверки логов прямо в процессе выполнения кода.

С помощью JMina можно оперативно находить ошибки там, где они возникают, проверять ключевые этапы работы системы и мгновенно останавливать тесты при появлении неожиданных проблем. Эта утилита уже помогает мне делать тестирование стабильнее и эффективнее — надеюсь, она будет полезна и вам.

P.S. Наиболее частые причины flaky-тестов (по моему опыту):

  • Влияние других тестов в Pipeline.
    Некоторые тесты могут переопределять Bean'ы с помощью Mock'ов, менять настройки или оставлять грязные данные. И далеко не все тесты корректно очищают изменения после себя.

  • Scheduled tasks.
    Задачи, запускаемые по расписанию, могут начать выполняться во время прохождения тестов и повлиять на результат. Рекомендую отключать планировщики в тестовой среде.

  • Неправильная работа со временем.
    Некоторые тесты могут проходить или падать в зависимости от текущего времени. Чаще всего проблемы возникают около полуночи, в первый и последний день месяца или года.

  • Race condition.
    Неприятная проблема: иногда крайне сложно понять, что именно произошло. Но лучше, чтобы у вас падали тесты, а не продакшн.

  • Отсутствие сортировки там, где она требуется. При работе с небольшими таблицами в базе данных строки часто возвращаются в порядке вставки или в порядке материального индекса. Но если явная сортировка не указана в запросе, СУБД не гарантирует порядок строк.

Теги:
Хабы:
+2
Комментарии4

Публикации

Работа

Ближайшие события