Ускоряем запуск юнит-тестов

zhitkov

Konstantin Zhitkov

Posted on January 6, 2024

Ускоряем запуск юнит-тестов

С ростом проекта юнит-тестов становится все больше, а время их выполнения увеличивается. Медленная работа Jest ухудшает DX и замедляет прохождение CI/CD. Это можно исправить.

Дисклеймер. Показанные ниже способы ускорения Jest будут отличаться в зависимости от мощности устройства, среды разработки, количества и качества тестов.

Теория

Давайте разберемся, как Jest использует ресурсы системы при выполнении тестов.

Одно ядро резервируется для работы CLI. Для остальных ядер создается по воркеру. Воркеры выполняют тесты параллельно друг другу. На устройстве с 8-ядерным процессором параллельно работают 7 воркеров.

Проверить количество воркеров можно с помощью команды

npx jest --showConfig | grep maxWorkers
Enter fullscreen mode Exit fullscreen mode

При запуске Jest с флагом --watch, воркеров будет в 2 раза меньше.

Jest распределяет тестовые наборы по воркерам равномерно. Благодаря этому воркеры заканчивают выполнение тестов одновременно.

Замеряем текущую скорость

Замерим исходное время прохождения тестов:

npx jest --no-cache --coverage=false
Enter fullscreen mode Exit fullscreen mode

Обратим внимание на 2 вещи: время работы команды (внизу) и время выполнения каждого отдельного файла (справа).

PASS tests/test-suite-1.test.ts (10.045s)
PASS tests/test-suite-2.test.ts (9.152s)
PASS tests/test-suite-3.test.ts (15.538s)
...
PASS tests/test-suite-146.test.ts (3.522s)
PASS tests/test-suite-147.test.ts (4.958s)
PASS tests/test-suite-148.test.ts (2.216s)

Test Suites: 148 passed, 148 total
Tests:       478 passed, 478 total
Snapshots:   0 total
Time:        152.774 s
Enter fullscreen mode Exit fullscreen mode

Если тесты в файле выполняются дольше 5 секунд, Jest выводит затраченное время рядом с именем файла. Это значение настраивается с помощью опции slowTestThreshold.

Итак, 148 тестовых наборов выполняются за 152 секунды. Максимальное время выполнения одного набора – 19.5 секунд. Минимальное – 0.25 секунд. Среднее – 6.6 секунд.

Уменьшаем количество воркеров

Jest сильно нагружает систему, используя большинство ядер для выполнения тестов. Из-за этого ухудшается итоговая производительность, что приводит к появлению флаки-тестов.

Проверим, уменьшится ли время работы Jest, если использовать только три четверти ядер:

npx jest --maxWorkers=75% --no-cache --coverage=false
Enter fullscreen mode Exit fullscreen mode

Задавать значения maxWorkers можно в числах, либо в процентах.

Можно провести бенчмарк для разных значений --maxWorkers, чтобы найти оптимальное количество воркеров. В моем случае это 4 воркера.

Workers Time Max Avg Total
7 152s 26.7s 7.9s 1161s
6 114s (-25%) 15.5s 4.4s 652s
4 95s (-37%) 🏆 8.2s 2.4s 363s
2 105s (-30%) 5.8s 1.4s 209s
1 161s (+6%) 4.9s. 1.1s 159s

Любопытно, что с одним ядром тесты выполняются всего на 6% медленнее, чем при использовании всех 7 ядер. При этом, каждый тестовый набор в среднем (Avg) прогоняется почти в 8 раз быстрее.

Используем более производительный компилятор TS

Jest использует babel-jest для компиляции TypeScript. Если использовать более быстрые инструменты, можно ускорить общее время выполнения тестов.

Одним из возможных решений является swc/jest. Это трансформер, использующий производительный Rust для компиляции TS-файлов.

Чтобы его подключить, установим его в качестве dev-зависимости:

npm i @swc/jest -D
Enter fullscreen mode Exit fullscreen mode

Затем обновим конфиг Jest:

// jest.config.js

module.exports = {
  transform: {
    '.*\\.(tsx?)$': [
      '@swc/jest',
      {
        jsc: {
          transform: {
            react: {
              runtime: 'automatic',
            },
          },
        },
      },
    ],
  },
};
Enter fullscreen mode Exit fullscreen mode

Проверим, ускорится ли время работы Jest при использовании SWC:

Workers Time Max Avg Total
7 118s 17.1s 5.9s 866s
6 104s (-12%) 21.5s 3.9s 590s
4 77s (-34%) 🏆 6.1s 1.9s 294s
2 98s (-17%) 3.9s 1.3s 193s
1 134s (+13%) 3.1s. 1.1s 130s

Как видно, 4 воркера снова показали лучший результат. При этом, скорость увеличилась на 50% относительно изначального значения.

Избавляемся от утечек памяти

Перед выполнением каждого теста Jest формирует require cache. Это дерево зависимостей, необходимых для этого теста. После выполнения теста дерево зависимостей очищается. Таким образом Jest создает изолированную среду для выполнения тестов.

Если Jest эффективно чистит require cache, то память, выделенная под дерево зависимостей, полностью высвобождается. Однако в некоторых ситуациях garbage collector не срабатывает, в результате чего происходит утечка памяти.

Причины утечки могут быть различные. Одна из часто встречающихся – использование файлов реэкспорта. Это файлы, которые экспортируют другие модули, при этом сами по себе не содержат кода. Jest не знает, где именно в этом файле находится нужный модуль, поэтому при построении дерева зависимостей добавляет туда все экспортируемые модули (а также их зависимости). Такой паттерн часто встречается в больших библиотеках вроде MUI (Material UI).

Проверим, есть ли подобная проблема с нашими тестами. Для этого запустим Jest в режиме профилирования используемой памяти (--logHeapUsage). Необходимо запускать все тесты последовательно в одном треде, поэтому нужно поставить флаг --maxWokers=1 или его аналог --runInBand.

node --expose-gc ./node_modules/.bin/jest --logHeapUsage --maxWorkers=1 --no-cache --coverage=false
Enter fullscreen mode Exit fullscreen mode

Если количество выделенной памяти не увеличивается – значит, утечек памяти нет. Jest успешно чистит кэш между тестами, а Garbage collector высвобождает ненужную память.

Если же количество выделенной памяти стремительно растет, то она очищается не эффективно.

В моем случае каждый тест увеличивал используемую память на 100-200Мб.

PASS tests/test-suite-1.test.ts (502 MB heap size)
PASS tests/test-suite-2.test.ts (576 MB heap size)
PASS tests/test-suite-3.test.ts (646 MB heap size)
...
PASS tests/test-suite-146.test.ts (3179 MB heap size)
PASS tests/test-suite-147.test.ts (3183 MB heap size)
PASS tests/test-suite-148.test.ts (3190 MB heap size)
Enter fullscreen mode Exit fullscreen mode

На графике видно, что память принудительно очистилась, достигнув лимита на уровне около 3500Мб.

Память очистилась, достигнув лимита на уровне около 3500Мб

Чтобы найти проблемные файлы, можно воспользоваться экспериментальным флагом --detectLeaks:

npx jest --detectLeaks --no-cache --coverage=false
Enter fullscreen mode Exit fullscreen mode

Чтобы найти конкретное место протечки, запустим Jest через node с флагом --inspect-brk. Так мы сможем использовать Chrome DevTools для дебага:

node --inspect-brk --expose-gc ./node_modules/.bin/jest --logHeapUsage --maxWorkers=1 --no-cache --coverage=false
Enter fullscreen mode Exit fullscreen mode

Затем откроем chrome://inspect в Chrome и нажмем Open dedicated DevTools for Node.

DevTools

Откроем вкладку Memory и запустим профилирование в режиме Allocation instrumentaion on timeline.

Вкладка Memory

После замены всех импортов иконок MUI с named-импорта на path-import, прирост утекающей памяти сократился в разы.

PASS tests/test-suite-1.test.ts (286 MB heap size)
PASS tests/test-suite-2.test.ts (277 MB heap size)
PASS tests/test-suite-3.test.ts (306 MB heap size)
PASS tests/test-suite-146.test.ts (289 MB heap size)
PASS tests/test-suite-147.test.ts (330 MB heap size)
PASS tests/test-suite-148.test.ts (364 MB heap size)
Enter fullscreen mode Exit fullscreen mode

Тесты стали выполняться за 24 секунды, что в 6 раз быстрее изначального значения!

Чтобы избежать подобных проблем в будущем, можно добавить правило ESLint:

{
  'no-restricted-imports': [
    'error',
    {
      paths: [
        {
          name: '@mui/icons-material',
          message: 'Please use path imports like @mui/icons-material/Alarm instead.',
        },
      ],
    },
  ],
}
Enter fullscreen mode Exit fullscreen mode

Дополнительные ресурсы:

💖 💪 🙅 🚩
zhitkov
Konstantin Zhitkov

Posted on January 6, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related