
Всем привет! На связи Николай Мезинов, разработчик фронтенда в продуктовой команде DevPlatform. Хочу поделиться опытом, как мы ускоряли прохождение e2e-тестов на Cypress в пайплайнах GitLab.
Зачем нам Cypress
Наша команда создает технологическую платформу для ИТ-специалистов и развивает идею, когда разработчик полностью владеет контекстом в какой-то подсистеме.

Мы стараемся объединить все пункты в одном разработчике, чтобы увеличить качество и скорость поставки. На выходе получается сильный инженерный продукт, который разрабатывается небольшой командой из 12 человек.
Чтобы поддерживать качество продукта, мы используем интеграционные и unit-тесты. А e2e-тесты на Cypress работают как последний рубеж для тестирования всей системы целиком от лица конечного пользователя.
Фреймворк Cypress выбрали из-за его простоты. У него много возможностей и плагинов «из коробки», а также большое сообщество.
Сначала работа с репозиторием e2e-тестов была простой и приятной для контрибьюторов, пока не начали блокироваться релизы. Это произошло потому, что любое новое приложение в нашей системе должно было пройти все е2е-тесты, иначе кнопка «Релиз» не активировалась в пайплайне GitLab. Накопилось большое количество критических сценариев и увеличилось количество тестов. Это стало для нас бутылочным горлышком и блокером релизов.
Тесты в Cypress проходят последовательно — и такой порядок замедляет их прохождение. Пайплайны в GitLab построены так, что деплой в продакшен недоступен, пока не пройдут e2e-тесты. Мы ждали по 25—30 минут, чтобы нажать кнопку «Deploy prod». А если по какой-то причине один из тестов падал, приходилось делать «Retry» и снова ждать.
Запуск тестов и параллелизация
Перед параллелизацией расскажу, как запускаем e2e-тесты. Мы используем Node.js-проект, а команды для запуска находятся в блоке scripts в файле package.json.
"scripts": {
"e2e": "cypress run --browser chrome --reporter mocha-multi-reporters --reporter-options configFile=cypress-reporters.json"
}
Нужна определенная структура папок, чтобы писать тесты на Cypress. Сами тесты хранятся в папке ./cypress/integration, вспомогательные утилиты — в папке ./cypress/support. Когда запускаем команду cypress run, Cypress начинает последовательно гонять все тесты из папки ./cypress/integration. Прогнать только часть файлов можно с помощью опции spec. В ней указываем, какие тесты хотим запустить:
yarn e2e --spec "./cypress/integration/first-test.spec.ts,./cypress/integration/third-test.spec.ts"
Запустить все тесты и равномерно распределить их по разным нодам можно с помощью инструкции parallel. Она нужна для того, чтобы запускать одну и ту же джобу несколько раз параллельно.
e2e:
image: some-image-for-cypress
stage: e2e
parallel: 4
script:
- yarn e2e

При этом для каждой джобы в рантайме дополнительно доступны 2 ENV-переменные: CI_NODE_INDEX и CI_NODE_TOTAL. Индекс текущей ноды CI_NODE_INDEX начинается с единицы, а не с нуля, что может быть непривычно для языков программирования. Благодаря этим ENV-переменным появляется гибкость, которая позволяет на разных нодах запускать разный набор тестов. Остается определить, какой набор тестов необходимо прогонять на каждой ноде, чтобы потом дополнить конфигурацию джобы:
e2e:
image: some-image-for-cypress
stage: e2e
parallel: 4
script:
- yarn e2e --spec "$specs"
Осталось определить список тестов, помеченных как переменная $specs. Мы отказались от Bash, потому что даже простой алгоритм на нем становится трудночитаемым.
Мы используем инструмент Zх, который позволяет использовать Bash-инструкции в JavaScript-like коде. К тому же «из коробки» доступно несколько полезных функций, которые не нужно заранее подключать. Подробнее о возможностях Zx можно прочитать в официальной документации на GitHub. Покажу алгоритм с пояснениями, чтобы продемонстрировать все, о чем рассказал.
#!/usr/bin/env zx
const nodeTotal = Number(process.env.CI_NODE_TOTAL) || 1; // Количество нод, на которых параллельно запускаются тесты
const nodeIndex = Number(process.env.CI_NODE_INDEX) || 1; // Индекс текущей ноды (индексация в GitLab начинается с 1)
// Найдем все тесты, утилита globby доступна «из коробки» Zx
const allSpecs = await globby('./cypress/integration/*.spec.ts');
// Из списка всех тестов определим те, что будут запущены на текущей ноде
const specsForCurrentNode = [];
for (let i = 0; i < allSpecs.length; i++) {
if (i % nodeTotal === nodeIndex - 1) {
specsForCurrentNode.push(allSpecs[i]);
}
}
// Объединим найденные тесты в строку, соединенную запятой, так как в таком формате ожидает опция --spec
const specsForCurrentNodeString = specsForCurrentNode.join(',');
// Выведем результат в консоль
console.log(specsForCurrentNodeString);
С помощью несложного цикла мы определили, какие тесты будут запущены на текущей ноде. Такой скрипт будет запускаться на каждой ноде, и для каждой сформируется свой список тестов. При этом все тесты будут распределены максимально равномерно. Теперь остается доработать конфигурацию джобы в GitLab.
e2e:
image: some-image-for-cypress
stage: e2e
parallel: 4
script:
- npm i -g zx
- specs=$(./scripts/get-specs-for-current-node.mjs)
- yarn e2e --spec "$specs"
В каждой джобе запускается свой набор тестов, что позволяет существенно ускорить прохождение пайплайна.

Вместо заключения
До параллелизации тесты гонялись примерно 25—30 минут, на иллюстрации выше приведен пример прогона тестов за 28 минут, после параллелизации тесты на разных нодах гоняются от 4 до 11 минут. Так в нашем конкретном случае мы ускорили прохождение тестов примерно в 2,5 раза.
При увеличении опции parallel можно добиться еще большего ускорения, но оно уже не будет столь значительным. Спасибо, что дочитали статью. Если есть вопросы — готов ответить в комментариях.