Как мигрировать с mocha на jest в 15 простых шагов — и зачем

Оригинал поста на хабре

Уже давно я заглядывался на фреймворк для тестирования 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. “Это может сработать!” - подумал я, и решил так же быстро попробовать, взлетит ли джест с мокой в виде тест раннера.

Ну что же.

1
npm install --save-dev jest jest-runner-mocha

Пишем простой конфиг запуска jest-test.config.js

1
2
3
4
5
module.exports = {
runner: 'jest-runner-mocha',
testRegex: 'tests/.*test_.*\\.js$',
maxWorkers: 3
};

и запускаем тесты

1
node --use_strict ./node_modules/.bin/jest --no-cache --config jest-test.config.js

Да, я большой фанат глобального стрикт мода, поэтому запуск выглядит именно так. Кстати, кеш - тоже очень крутая фишка jest. А отключил я его для отладки на всякий случай - включить его потом всегда можно. И - внезапный результат - <u>мгновенно прошло порядка 90% тестов</u>, что было прямо феерически хорошим результатом! И было ощущение, что прошли они чуть быстрее - точно понять было нельзя, так как некоторые не проходившие тесты тупо зависали.

2. Выбор репортера

Для джеста, как и для моки, есть богатый выбор репортеров. Тот, который по умолчанию, тоже клёвый - при запуске ты чувствуешь себя не разработчиком, а пилотом космического корабля. Это очень круто, но при наличии сотен наборов быстрых тестов ты всё равно не видишь там ничего полезного, а история терминала просто засоряется. Так что я выбрал простой jest-dot-reporter - он рисует progress bar и говорит количество прошедших, упавших и выключенных тестов - ничего лишнего.

Выбор репортера делается через CLI опции или через config:

1
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 не мог их сбросить так как ничего не знал про них.

Так что в конфиг джеста добавились опции:

1
2
setupFilesAfterEnv: [ 'lib/clearMocks.js'],
clearMocks: true,

Ну а lib/clearMocks.js представляет собой

1
2
3
4
5
6
7
8
9
// const jest = require('jest-mock');
const sinon = require('sinon');

module.exports = {
clearMocks: () =&gt; {
// jest.clearAllMocks(); возможно, сможет скидывать нативные моки джеста, надо проверять
sinon.sandbox.restore();
}
};

Пулл реквест в оригинальный репозиторий мока раннера был создан и даже обсуждается. После этого процент проходящих тестов стал заметно больше. А на некоторых тестах стало видно, каких моков им не не хватает - и удалось это быстро поправить.

5. Правим оставшиеся тесты

Конечно, остались какие-то кривые единичные случаи. Например, нестабильно воспроизводимая проблема, когда при некоей последовательности запуска тестов падали циклические зависимости (вот почему я всегда говорю их не использовать). Ещё были тесты с использованием фальшивых таймеров без их восстановления. Но таких проблем осталось не слишком много, и поправили мы их довольно быстро. Ура - тесты проходят! Думаете, всё? Нет, история продолжается!

6. Сотни ивентов

При запуске тестов я обратил внимание на то, что при запуске тестов на process вешается 500 обработчиков exit. Что-то явно шло не так. В коде раннера я заметил прекрасный кусок

1
process.on('exit', () =&gt; 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:

1
2
3
4
5
6
7
8
9
10
&gt; core-js@3.6.4 postinstall
&gt; 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:
&gt; https://opencollective.com/core-js
&gt; 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. Описание миграции

Так как нужно рассказать коллегам, что было сделано, почему, и как они при желании могут повторить миграцию - собственно была написана эта статья, как максимально подробный и удобный формат руководства. Опять же, я корыстно надеюсь на помощь сообщества и на всякие полезные комментарии.

15. Обновление

Этот параграф я дописал чуть позже. Нам таки удалось обновить все зависимости, починить юнит тесты и избавиться от лок файла. Жизнь прекрасна!

Результат

Так что в итоге мы получили после миграции?

  1. Скорость тестов и линтинга локально и в CI увеличилась примерно в 3 раза на 3х потоках. Наверное, можно и больше, пока просто мало играл с количеством воркеров.
  2. Ушли завязки тестов друг на друга. То что происходит в тесте - остаётся в тесте. Более того, разбивка тестов на несколько процессов даёт возможность выявления дополнительных не очевидных ошибок.
  3. Можно использовать встроенные возможности jest вместо всего того зоопарка решений, который развёлся за эти годы.

ToDo

Что ещё осталось сделать:

  1. Пока я не смотрел, как работает проверка покрытия. Возможно, там будут проблемы. Но это не очень критично, так как mocha никуда не уходит, и можно спокойно считать покрытие как раньше.
  2. Можно дать разработчикам возможность писать тесты полностью на jest - просто сделать другой префикс для тестовых файлов. Но это породит некоторый зоопарк и проблемы с тем что придётся сливать между собой покрытие от разных раннеров - так что надо ещё подумать, стоит ли.
  3. Возможно, стоит теперь ещё раз пройтись по коду при помощи jest-codemods и посмотреть, что можно безопасно заменить - например, сменить для начала библиотеку для ассертов.

Да, если что, вот ссылка на мой форк раннера mocha. Поставить его можно командой

1
npm install --save-dev @kernel-panic/jest-runner-mocha

А в конфиге прописать как

1
runner: '@kernel-panic/jest-runner-mocha',