Уже давно я заглядывался на фреймворк для тестирования jest, в котором есть огромное количество всяких вкусных фишек, одна из которых — многопоточное выполнение тестов. При условии того, что у меня был проект на 5000 юнит тестов, миграция обещала быть крайне полезной. Далее я расскажу 14 простых шагов, за которых мне удалось мигрировать — пусть и с некоторыми оговорками — и что мы в результате получили. Спойлер — всё получилось очень круто.
Исходные условия
Дано — монолит с огромным количеством кода, о котором я рассказывал ранее, и который мы продолжаем растаскивать. Давайте не холиварить на тему монолитов в этом посте — всё что я хотел и мог сказать — уже было сказано ранее. Понятно, что проект должен быть разбит, и тогда самой проблемы запуска 5000 юнит тестов не возникнет — но не всё получается сделать сразу.
Для юнитов используется mocha, chai, sinon, rewire, nock, nyc — вот такая вот сборная солянка, которая полностью покрывает наши потребности. В юнитах есть несколько известных, но сложно диагностируемых и исправляемых проблем:
1) Местами используется глобальный sandbox, оставшихся с тех времён, когда ещё был sinon.sandbox.
2) Из-за глобального сендбокса часть юнитов завязаны друг на друга — они используют чужие моки. Это очевидно плохо, но такие случаи сложно выявлять и исправлять.
3) Местами используется глобальная подмена таймеров через sinon.useFakeTimers. И иногда тесты тоже завязываются на таймеры друг друга. Или последующий тест сбрасывает изменения таймеров от предыдущего.
4) Ну и наконец прохождение юнитов занимает порядка 13 секунд. Что в целом терпимо, но некоторые разработчики жалуются на то, что время препуш хука с тестами каждый раз сокращает время их рабочего дня на эти самые 13 секунд.
Почему мультитрединг
Как я уже написал чуть выше, Jest мне в первую очередь был интересен тем, что умеет запускать тесты в несколько потоков. Так как это бекенд тесты безо всякого там puppeteer и прочих внешних компонентов, то юниты фактически не имеют в себе никаких асинхронных операций (не путать с асинхроными функциями) — и что запускай их параллельно что последовательно — время исполнения не изменится, только забьёшь ивент луп и оперативку. Так что в данном случае мультитрединг — фактически единственное и оптимальное средство оптимизации. Было понятно, что возникнут накладные расходы на инициализацию — но было непонятно, насколько они будут велики.
Оставь надежду всяк сюда входящий
Сначала я пробовал использовать какие-то имеющиеся решения для запуска mocha в многопоточном режиме — но таких решений было полторы штуки, и они вываливались с такими стрёмными ошибками, что даже дебажить это не было никакого желания. Например, я точно смотрел mocha-parallel-tests от Дмитрия Сорина, бывшего сотрудника Яндекса. Падало вдребезги — хотя возможно, что проблема была скорее в проекте, а не в раннере. Надеюсь, у Дмитрия всё получится.
Так же я наудачу пробовал просто взять и смигрировать тесты при помощи jest-codemods — но увы, всё тоже падало, судя по всему — ломалось на sinon — а править 5000 юнитов не было ни времени ни желания. Хотелось более простого решения.
Таки миграция!
1. Установка
В очередной рад с тоскливой завистью просматривая список фишек из awesome-jest, я вдруг заметил jest-runner-mocha. "Это может сработать!" — подумал я, и решил так же быстро попробовать, взлетит ли джест с мокой в виде тест раннера.
Ну что же.
npm install --save-dev jest jest-runner-mocha
Пишем простой конфиг запуска jest-test.config.js
module.exports = {
runner: 'jest-runner-mocha',
testRegex: 'tests/.*test_.*\.js$',
maxWorkers: 3
};
и запускаем тесты
node --use_strict ./node_modules/.bin/jest --no-cache --config jest-test.config.js
Да, я большой фанат глобального стрикт мода, поэтому запуск выглядит именно так.
Кстати, кеш — тоже очень крутая фишка jest. А отключил я его для отладки на всякий случай — включить его потом всегда можно.
И — внезапный результат — мгновенно прошло порядка 90% тестов, что было прямо феерически хорошим результатом! И было ощущение, что прошли они чуть быстрее — точно понять было нельзя, так как некоторые не проходившие тесты тупо зависали.
2. Выбор репортера
Для джеста, как и для моки, есть богатый выбор репортеров. Тот, который по умолчанию, тоже клёвый — при запуске ты чувствуешь себя не разработчиком, а пилотом космического корабля. Это очень круто, но при наличии сотен наборов быстрых тестов ты всё равно не видишь там ничего полезного, а история терминала просто засоряется. Так что я выбрал простой jest-dot-reporter — он рисует progress bar и говорит количество прошедших, упавших и выключенных тестов — ничего лишнего.
Выбор репортера делается через CLI опции или через config:
reporters: ['jest-dot-reporter'],
Кстати, его в списке awesome-jest почему-то не было. Теперь будет — пулл реквест я добавил.
3. Использование актуальной версии mocha
Есть у меня плохая привычка — смотреть код пакета, который я использую. И вот залез я в jest-runner-mocha. И обнаружил, что он использует для запуска тестов mocha версии 3.5. Когда как последняя — 7. Мейнтейнер на предложение обновиться говорит, что он хочет поддерживать Node 4. На аргументы, что
- Node 4 не поддерживает уже ни mocha ни jest, ни даже yarn, который любит ментейнер
- Уже Node 8 дошла до End Of Life
- Можно сделать обновление мажорной версии, а пользователи на старой ноде могут продолжать пользоваться прошлой версией
- Можно сделать mocha в виде peerDependency, и пользователь сам выберет свою версию
внятного ответа получено не было. Ну ладно. Делаем форк. В форке мока теперь в peerDependency — то есть будет использоваться та же версия, что указана в проекте.
4. Реализуем свой clearMocks
Как я уже сказал, в джесте есть воистину божественные фичи. Две из них — это сброс моков и фальшивых таймеров перед каждым набором тестов — что позволяет делать их действительно независимыми. Для особо упоротых граждан можно сбрасывать даже кеш загруженных модулей — имхо перебор, и говорит о непродуманной архитектуре — но я знаю проекты, в которых это делают.
К сожалению, непродолжительные изыскания привели к выяснению, что поддержка clearMocks должна быть реализована в самом тест раннере. К слову, тест раннер моки фактически является единственным представителем вида тест раннеров — потому что остальные раннеры джеста делают всякие более простые вещи вроде линтинга, и никак не связаны с тестированием. Так что кроме раннера моки есть только нативный джестовый раннер jasmine2. Вот в его коде как раз можно найти полную реализацию всяких плюшек — но мигрировать их долго и сложно.
Так что я решил пойти более коротким и грязным путём — добавил в свой форк поддержку опции setupFilesAfterEnv
, а в них — поддержку экспорта функции с именем clearMocks
и при её нахождении — вызов её перед каждым вызовом набора тестов. Не самое элегантное решение, но навскидку больше ничего не пришло в голову. Кроме того, поддержка clearMocks от jest мне никак не помогла бы — поскольку моки в проекте были от sinon, и jest не мог их сбросить так как ничего не знал про них.
Так что в конфиг джеста добавились опции:
setupFilesAfterEnv: [ 'lib/clearMocks.js'],
clearMocks: true,
Ну а lib/clearMocks.js
представляет собой
// const jest = require('jest-mock');
const sinon = require('sinon');
module.exports = {
clearMocks: () => {
// jest.clearAllMocks(); возможно, сможет скидывать нативные моки джеста, надо проверять
sinon.sandbox.restore();
}
};
Пулл реквест в оригинальный репозиторий мока раннера был создан и даже обсуждается.
После этого процент проходящих тестов стал заметно больше. А на некоторых тестах стало видно, каких моков им не не хватает — и удалось это быстро поправить.
5. Правим оставшиеся тесты
Конечно, остались какие-то кривые единичные случаи. Например, нестабильно воспроизводимая проблема, когда при некоей последовательности запуска тестов падали циклические зависимости (вот почему я всегда говорю их не использовать). Ещё были тесты с использованием фальшивых таймеров без их восстановления. Но таких проблем осталось не слишком много, и поправили мы их довольно быстро. Ура — тесты проходят! Думаете, всё? Нет, история продолжается!
6. Сотни ивентов
При запуске тестов я обратил внимание на то, что при запуске тестов на process
вешается 500 обработчиков exit
. Что-то явно шло не так. В коде раннера я заметил прекрасный кусок
process.on('exit', () => process.exit());
Мало того, что он не имеет никакого смысла, так этот ивент ещё и вешается при прохождении каждого набора тестов… В общем, в моём форке бага исправлена, а пулл реквест в оригинальный репозиторий пока изучают… slowpoke.jpg.
7. Ещё сотни ивентов
После того, как я разобрался с предыдущими багами, теперь я заметил, что на process
вешаются сотни обработчиков unCaughtException
. На этот раз, проблема оказалась не в раннере, а в самой моке — достаточно было обновиться с 7.0.0 до 7.0.1.
8. Крадём чужие пулл реквесты
Есть у меня привычка отсматривать пулл реквесты в проектах, которые мне интересны — в особенности в форках, которые я поддерживаю. Обнаружил отличный пулл реквест в jest-runner-mocha, который заменял всю ручную работу с воркерами на стандартный флоу в jest и исправлял связанные с этим баги. К сожалению, тут опять потерялась совместимость со священным граалем — четвёртой нодой — поэтому пулл реквест висит не принятый. Ну что же — я влил его в свой форк, а заодно выпилил из него сборку бабелем, совместимость с четвёртой нодой и yarn.
9. Зовём чувака на работу
Переставляя зависимости в основном проекте, внезапно обнаруживаю новый postinstall hook от транзитивной зависимости jest-runner-mocha:
> core-js@3.6.4 postinstall
> node -e "try{require('./postinstall')}catch(e){}"
Thank you for using core-js ( https://github.com/zloirock/core-js ) for polyfilling JavaScript standard library!
The project needs your help! Please consider supporting of core-js on Open Collective or Patreon:
> https://opencollective.com/core-js
> https://www.patreon.com/zloirock
Also, the author of core-js ( https://github.com/zloirock ) is looking for a good job -)
Не люблю такие вещи, поэтому пошёл смотреть, в чём дело. Выжимку можно прочитать в этом комментарии. Если вкратце — автор пакета c 23,528,407 еженедельных установок, одного из базовых компонентов бабеля — работал над ним бесплатно пять лет, а сейчас у него проблемы, огромный долг, и ему грозит тюрьма. Сообщество бабеля отказалось спонсировать его проект, на пожертвованиях он собрал 57 баксов, и получил тонны негативных комментариев за свой пост инсталл хук. После чего решил оставить этот хук на неопределённое время. Поддерживаю. Последний коммит автора был от 12 января. Надеюсь, с ним всё в порядке. Предложил ему работу в нашей компании. После написания поста ещё закину пожертвовование. Предлагаю вам сделать так же. Кстати, это не имеет значения, но он наш соотечественник.
Что же до пост инсталл хука — проблема с ним решилась сама чуть позже.
10. Переносим линтинг в джест
Не относится напрямую к тестированию, но если ты вдруг начал забивать гвозди молотком вместо ложки — очень сложно остановиться. Линтинг всего репозитория в CI у нас занимал порядка двух минут, и, поскольку он тоже полностью синхронный, мне так же давно хотелось сделать его многопоточным. Как-то давно я уже пробовал esprint — но там всё было не слава богу, и я на это забил.
Но для джеста есть отличный раннер jest-runner-eslint — который просто берёт движок еслинт, запускает его в несколько потоков, и агрегирует результаты. И надо сказать, что тут реально всё взлетело безо всяких плясок с бубнами. Линтинг ускоряется примерно в то количество раз, сколько у тебя воркеров — пока не упрёшься в память.
Надо сказать, что забавно, что всё новое — хорошо забытое старое. По факту Jest просто повторил всё то, что уже было в раннерах вроде grunt или gulp. Просто те раннеры раньше использовались для сборки фронта, а сейчас всё это, включая многопоточное выполнение задач, включил в себя вебпак. Так что Jest переоткрыл заново эту же нишу, сузив её и удобно реализовав...
11. Исправляем CI
Раньше в CI у нас параллельно выполнялся линтинг и юнит тесты, чтобы хоть как-то рационально использовать ядра на арендуемых виртуалках. Это иногда создавало кашу из вывода линтера и юнит тестов, но в целом было терпимо, и точно проходило быстрее, чем последовательно. Теперь можно было вернуться к последовательному исполнению — ведь и юниты и тесты проходили в несколько потоков!
12. Эпическая подстава
В какой-то момент я вдруг заметил, что у меня перестали проходить тесты на форке мока раннера. Какое-то время пришлось потратить на то, чтобы сравнить изменения… А потом я подумал, что видимо не просто так тесты должны были запускаться после установки при помощи yarn, и наверное не просто так там лежал файл yarn.lock. Да. Дело было в лок файле.
Это просто эпическая подстава. Потому что даже если вы используете yarn для управления зависимостями — лок файл от yarn внутри пакета использоваться не будет. Он используется только для верхнего уровня. Что это значит? То что тот код, который отрабатывает в юнит тестах на установленных из лок файла пакетах — будет работать некорректно внутри проекта.
Что делать? В качестве быстрого фикса, я использовал утилиту для конвертации лок файла от yarn в package-lock.json, после чего переименовал его в npm-shrinkwrap.json (их формат идентичен). Если вы не знали — лок файл игнорируется при установке, а вот shrinkwrap нет — и транзитивные зависимости вашей зависимости ставятся ровно тех версий, которые указаны в shrinkwrap. Конечно, это плохая практика, и в результате количество зависимостей в основном проекте чудовищно раздулось. Зато я уверен, что всё работает точно так, как должно. А эти зависимости всё равно уходят при сборке артефакта на npm prune --production
.
Фикс влит в мой форк, а в оригинальном репозитории его раскритиковали. Нет, я согласен, что лок файл в пакете это плохо. Но лучше, чем заведомо неработающий код. Как-нибудь я надеюсь поправить зависимости и избавиться от этого лока. Может быть, в оригинальном репозитории это поправят. Или может быть вы поможете?
13. Форматирование ошибок
И только на этом моменте я заметил, что ошибки выдаются в кривом формате — вместо красивых ассертов всё вываливается массивом текста со слетевшим форматированием и цветами. Грешил на многое, но проблема оказалась опять же в раннере. Возможно, более ранние версии jest умели воспринимать ошибки массивом, но сейчас там ожидается строка. Фикс есть в форке, в оригинальном репозитории пока без комментариев.
14. Описание миграции
Так как нужно рассказать коллегам, что было сделано, почему, и как они при желании могут повторить миграцию — собственно была написана эта статья, как максимально подробный и удобный формат руководства. Опять же, я корыстно надеюсь на помощь сообщества и на всякие полезные комментарии.
Результат
Так что в итоге мы получили после миграции?
- Скорость тестов и линтинга локально и в CI увеличилась примерно в 3 раза на 3х потоках. Наверное, можно и больше, пока просто мало играл с количеством воркеров.
- Ушли завязки тестов друг на друга. То что происходит в тесте — остаётся в тесте. Более того, разбивка тестов на несколько процессов даёт возможность выявления дополнительных не очевидных ошибок.
- Можно использовать встроенные возможности jest вместо всего того зоопарка решений, который развёлся за эти годы.
ToDo
Что ещё осталось сделать:
- Пока я не смотрел, как работает проверка покрытия. Возможно, там будут проблемы. Но это не очень критично, так как mocha никуда не уходит, и можно спокойно считать покрытие как раньше.
- Можно дать разработчикам возможность писать тесты полностью на jest — просто сделать другой префикс для тестовых файлов. Но это породит некоторый зоопарк и проблемы с тем что придётся сливать между собой покрытие от разных раннеров — так что надо ещё подумать, стоит ли.
- В пакете раннера ещё явно остались проблемы — в том числе с юнит тестами и зависимостями. Возможно, это ещё выстрелит в ногу. Или нет. Всё же, выборка в 5000 юнитов неплохо покрывает самые разные кейсы.
- Возможно, стоит теперь ещё раз пройтись по коду при помощи jest-codemods и посмотреть, что можно безопасно заменить — например, сменить для начала библиотеку для ассертов.
Да, если что, вот ссылка на мой форк раннера mocha.
Поставить его можно командой
npm install --save-dev @kernel-panic/jest-runner-mocha
А в конфиге прописать как
runner: '@kernel-panic/jest-runner-mocha',
Автор: jehy