Снимаем покрытие кода с уже запущенного Node.JS приложения
И снова я про тестирование и покрытие.
Наверное, вы уже поперхнулись кофе от вопроса “Зачем снимать покрытие с запущенного приложения” - но такая потребность периодически возникает.
Например:
- Узнать покрытие интеграционных тестов без инстурментализации кода, завершения приложения и выгрузки репорта какими-то сторонними средствами;
- Узнать без долгого ковыряния кода, по каким именно модулям приложения прошёл запрос;
- Определить “мёртвый” код, который по факту не используется в приложении;
- Узнать список транзитивных зависимостей, которые используются на определённые запросы.
Интересно? Поехали!
Откуда у вас такие картинки
Недавно я занимался написанием тест раннера для jest и mocha (кстати, в итоге вышло просто отлично), и узнал, что в V8 появилась возможность снимать покрытие без применения какой-либо дополнительной инструментализации. Это показалось мне безумно крутым - хоть часть оптимизаций выключается, но производительность кода падает не так сильно, как в случае прогона его через инструментализацию бабелем. Это значит, что мы можем в любой момент включать и выключать покрытие, и выгружать его чуть ли не с продакшн серверов!
Что получилось и как подключить
В общем, я закатал рукава и написал вот такую штуку.
Проще всего будет показать, как она работает, на простом примере с express.
- Подключаем библиотеку
1 | const runtimeCoverage = require('runtime-coverage'); |
- Публикуем API endpoint, который включает покрытие:
1 | app.get('/startCoverage', async (req, res) => { |
- Публикуем endpoint, который выдаёт данные покрытия
1 | app.get('/getCoverage', async (req, res) => { |
Собственно… Всё!
Теперь можно дёрнуть первый endpoint, потом любые другие API, потом второй - и получить в ответ покрытие! По умолчанию в текстовом формате, но вообще поддерживаются любые стандартные форматы, например, кубертюра.
Примеры работы
Например, для проекта-примера можно дёрнуть http://localhost:3000/startCoverage затем http://localhost:3000/getCoverage и получить в ответ
1 | ----------|---------|----------|---------|---------|--------------------- |
А если чуть поиграться с настройками не убирать из покрытия node_modules, то можно узнать, по каким node modules пробегается запрос:
1 | -------------------------------------------------------------|---------|----------|---------|---------|------------------------- |
</spoiler>
На всякий случай уточню ещё раз, что это минимальные примеры - без обработки ошибок, без чтения покрытия потоком (что тоже поддерживается), без какой-либо авторизации (вы явно не хотите, чтобы этот endpoint был публичен).
Как оно работает, и что вам сломает
Это параграф для любопытных. Вряд ли вас бует много, но всё же.
Фактически, я закинул в один котёл библиотеки collect-v8-coverage (простая библиотека для вызова профайлера), v8-to-istanbul, istanbul-lib-coverage, istanbul-lib-report, istanbul-reports - и довольно бысто начал получать примерно то что хотел. Единственная сложность возникла с тем, что V8 выдаёт полные данные о покрытии файла только если ты его грузишь уже после включения профайлера. Иначе удастся получить только данные о покрытиии вызванной функции - а на этом отчёта не построишь.
Но всё почти работало, поэтому я дописал хак - после получения фактического покрытия, мы отдельно получаем пустое, и накладываем одно на другое. Звучит довольно просто, но для получения этого самого пустого покрытия пришлось делать довольно стрёмные вещи:
- Получаем список файлов, которые нас интересуют;
- Включаем режим покрытия;
- Перезагружаем require на прокси, с геттером, который рекурсивно выдаёт себя же;
- Идём циклом по нужным файлам; 4.1) Запоминаем кеш require, после чего убираем его; 4.2) Грузим модуль, и пытаемся вызвать из него экспорты, если они функции, ловим и игнорируем все ошибки; 4.3) Возвращаем на место старый кеш require;
- Возвращаем на место require;
- Выключаем режим покрытия;
- Получаем покрытие, и ставим вызов всех блоков в ноль.
В общем, звучит довольно стрёмно, но, поскольку перезагрузки выполняются синхронно и между ними ничего не может вклиниться - это более-менее безопасно - повторно загруженные ради покрытия файлы просто не смогут ничего сделать из-за фактически отключенных внешних библиотек. Разве что несколько кейсов я смог придумать:
- В случае использования глобальных переменных код таки сможет их вызвать.
- Могут остаться всякие демонические вещи вроде setInterval
- могут сыпаться ошибки вроде unhandledRejction, если есть кому их ловить.
В общем, вроде не слишком критично, но я бы рекомендовал убивать приложение после получения данных покрытия. Чисто от греха подальше. Ну или вместо forceReload
использовать forceLineMode
- тогда библиотека не занимается всей этой перегрузкой, и даст примерную оценку по строкам кода.
UPD. Чуть доделал - теперь для сбора пустого покрытия просто используется отдельный процесс, который изолирован от основного - так что вероятность сайд эффектов пренебрежимо мала.
Если вдруг у кого есть мысли и знакомые люди, которые съели собаку на работе с покрытием (а не как я) - буду рад предложениям и пулл реквестам. Смайлик.