Ускоряем запуск юнит-тестов
Published: 2024-01-06 18:53:56

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

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

## Теория

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

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

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

```
npx jest --showConfig | grep maxWorkers
```

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

Jest распределяет тестовые наборы по воркерам [равномерно](https://jestjs.io/blog/2016/03/11/javascript-unit-testing-performance). Благодаря этому воркеры заканчивают выполнение тестов одновременно.

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

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

```
npx jest --no-cache --coverage=false
```

Обратим внимание на 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
```

> Если тесты в файле выполняются дольше 5 секунд, Jest выводит затраченное время рядом с именем файла. Это значение настраивается с помощью опции [`slowTestThreshold`](https://jestjs.io/ru/docs/configuration#slowtestthreshold-number).

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

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

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

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

```
npx jest --maxWorkers=75% --no-cache --coverage=false
```

> Задавать значения `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
```

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

```
// jest.config.js

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

Проверим, ускорится ли время работы 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
```

Если количество выделенной памяти не увеличивается – значит, утечек памяти нет. 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)
```

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

![Память очистилась, достигнув лимита на уровне около 3500Мб](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/5ks9j2xzhqss7djwo86c.png)

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

```
npx jest --detectLeaks --no-cache --coverage=false
```

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

```
node --inspect-brk --expose-gc ./node_modules/.bin/jest --logHeapUsage --maxWorkers=1 --no-cache --coverage=false
```

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

![DevTools](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/n0yh5pwcyto3wfh5p36r.png)

Откроем вкладку _Memory_ и запустим профилирование в режиме _Allocation instrumentaion on timeline_.


![Вкладка Memory](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/6qqlejlamk6iddm5mgc9.png)

После замены всех импортов иконок 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)
```

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

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

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

---

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

- [Improve Jest Runner Performance](https://blog.developer.adobe.com/improve-jest-runner-performance-a8f56708ba94)
- [JavaScript Unit Testing Performance
](https://jestjs.io/blog/2016/03/11/javascript-unit-testing-performance)
- [Make Your Jest Tests up to 20% Faster by Changing a Single Setting](https://dev.to/vantanev/make-your-jest-tests-up-to-20-faster-by-changing-a-single-setting-i36)
- [Speed ​​up TypeScript with Jest](https://miyauchi.dev/posts/speeding-up-jest/)
- [Finding the cause of a memory leak in Jest tests](https://making.close.com/posts/finding-the-cause-of-a-memory-leak-in-jest)
[How to solve the Memory Leak problem on Node.js while running Jest](https://plazagonzalo.medium.com/how-to-solve-memory-leak-problems-on-node-js-while-running-jest-a33839d49bb1&sca_esv=591216765&strip=1&vwsrc=0)
- [The process to identify and resolve memory leaks in Jest: A step-by-step guide](https://copyprogramming.com/howto/what-are-the-steps-to-follow-to-debug-memory-leak-in-jest)
- [Jest Memory in Kubernetes](https://medium.com/@curtis.porter/jest-memory-in-kubernetes-833a25fc4838)