Как мигрировать с mocha на jest в 15 простых шагов — и зачем
Уже давно я заглядывался на фреймворк для тестирования jest, в котором есть огромное количество всяких вкусных фишек, одна из которых - многопоточное выполнение тестов. При условии того, что у меня был проект на 5000 юнит тестов, миграция обещала быть крайне полезной. Далее я расскажу 14 простых шагов, за которых мне удалось мигрировать - пусть и с некоторыми оговорками - и что мы в результате получили. Спойлер - всё получилось очень круто.
Исходные условия
Дано - монолит с огромным количеством кода, о котором я рассказывал ранее, и который мы продолжаем растаскивать. Давайте не холиварить на тему монолитов в этом посте - всё что я хотел и мог сказать - уже было сказано ранее. Понятно, что проект должен быть разбит, и тогда самой проблемы запуска 5000 юнит тестов не возникнет - но не всё получается сделать сразу.
Для юнитов используется mocha, chai, sinon, rewire, nock, nyc - вот такая вот сборная солянка, которая полностью покрывает наши потребности. В юнитах есть несколько известных, но сложно диагностируемых и исправляемых проблем:
- Местами используется глобальный sandbox, оставшихся с тех времён, когда ещё был sinon.sandbox.
- Из-за глобального сендбокса часть юнитов завязаны друг на друга - они используют чужие моки. Это очевидно плохо, но такие случаи сложно выявлять и исправлять.
- Местами используется глобальная подмена таймеров через sinon.useFakeTimers. И иногда тесты тоже завязываются на таймеры друг друга. Или последующий тест сбрасывает изменения таймеров от предыдущего.
- Ну и наконец прохождение юнитов занимает порядка 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 | module.exports = { |
и запускаем тесты
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 | setupFilesAfterEnv: [ 'lib/clearMocks.js'], |
Ну а lib/clearMocks.js
представляет собой
1 | // const jest = require('jest-mock'); |
Пулл реквест в оригинальный репозиторий мока раннера был создан и даже обсуждается. После этого процент проходящих тестов стал заметно больше. А на некоторых тестах стало видно, каких моков им не не хватает - и удалось это быстро поправить.
5. Правим оставшиеся тесты
Конечно, остались какие-то кривые единичные случаи. Например, нестабильно воспроизводимая проблема, когда при некоей последовательности запуска тестов падали циклические зависимости (вот почему я всегда говорю их не использовать). Ещё были тесты с использованием фальшивых таймеров без их восстановления. Но таких проблем осталось не слишком много, и поправили мы их довольно быстро. Ура - тесты проходят! Думаете, всё? Нет, история продолжается!
6. Сотни ивентов
При запуске тестов я обратил внимание на то, что при запуске тестов на process
вешается 500 обработчиков exit
. Что-то явно шло не так. В коде раннера я заметил прекрасный кусок
1 | 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:
1 | > core-js@3.6.4 postinstall |
Не люблю такие вещи, поэтому пошёл смотреть, в чём дело. Выжимку можно прочитать в этом комментарии. Если вкратце - автор пакета 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. Обновление
Этот параграф я дописал чуть позже. Нам таки удалось обновить все зависимости, починить юнит тесты и избавиться от лок файла. Жизнь прекрасна!
Результат
Так что в итоге мы получили после миграции?
- Скорость тестов и линтинга локально и в CI увеличилась примерно в 3 раза на 3х потоках. Наверное, можно и больше, пока просто мало играл с количеством воркеров.
- Ушли завязки тестов друг на друга. То что происходит в тесте - остаётся в тесте. Более того, разбивка тестов на несколько процессов даёт возможность выявления дополнительных не очевидных ошибок.
- Можно использовать встроенные возможности jest вместо всего того зоопарка решений, который развёлся за эти годы.
ToDo
Что ещё осталось сделать:
- Пока я не смотрел, как работает проверка покрытия. Возможно, там будут проблемы. Но это не очень критично, так как mocha никуда не уходит, и можно спокойно считать покрытие как раньше.
- Можно дать разработчикам возможность писать тесты полностью на jest - просто сделать другой префикс для тестовых файлов. Но это породит некоторый зоопарк и проблемы с тем что придётся сливать между собой покрытие от разных раннеров - так что надо ещё подумать, стоит ли.
- Возможно, стоит теперь ещё раз пройтись по коду при помощи jest-codemods и посмотреть, что можно безопасно заменить - например, сменить для начала библиотеку для ассертов.
Да, если что, вот ссылка на мой форк раннера mocha. Поставить его можно командой
1 | npm install --save-dev @kernel-panic/jest-runner-mocha |
А в конфиге прописать как
1 | runner: '@kernel-panic/jest-runner-mocha', |