И снова я про тестирование и покрытие.
Наверное, вы уже поперхнулись кофе от вопроса "Зачем снимать покрытие с запущенного приложения" — но такая потребность периодически возникает.
Например:
- Узнать покрытие интеграционных тестов без инстурментализации кода, завершения приложения и выгрузки репорта какими-то сторонними средствами;
- Узнать без долгого ковыряния кода, по каким именно модулям приложения прошёл запрос;
- Определить "мёртвый" код, который по факту не используется в приложении;
- Узнать список транзитивных зависимостей, которые используются на определённые запросы.
Интересно? Поехали!
Откуда у вас такие картинки
Недавно я занимался написанием тест раннера для jest и mocha (кстати, в итоге вышло просто отлично), и узнал, что в V8 появилась возможность снимать покрытие без применения какой-либо дополнительной инструментализации. Это показалось мне безумно крутым — хоть часть оптимизаций выключается, но производительность кода падает не так сильно, как в случае прогона его через инструментализацию бабелем. Это значит, что мы можем в любой момент включать и выключать покрытие, и выгружать его чуть ли не с продакшн серверов!
Что получилось и как подключить
В общем, я закатал рукава и написал вот такую штуку.
Проще всего будет показать, как она работает, на простом примере с express.
1) Подключаем библиотеку
const runtimeCoverage = require('runtime-coverage');
2) Публикуем API endpoint, который включает покрытие:
app.get('/startCoverage', async (req, res) => {
await runtimeCoverage.startCoverage();
res.send('coverage started');
});
3) Публикуем endpoint, который выдаёт данные покрытия
app.get('/getCoverage', async (req, res) => {
const options = {
all: req.query.all,
return: true,
reporters: [req.query.reporter || 'text'],
};
const coverage = await runtimeCoverage.getCoverage(options);
const data = Object.values(coverage)[0];
res.end(data);
});
Собственно… Всё!
Теперь можно дёрнуть первый endpoint, потом любые другие API, потом второй — и получить в ответ покрытие! По умолчанию в текстовом формате, но вообще поддерживаются любые стандартные форматы, например, кубертюра.
Примеры работы
Например, для проекта-примера можно дёрнуть http://localhost:3000/startCoverage затем http://localhost:3000/getCoverage и получить в ответ
----------|---------|----------|---------|---------|---------------------
File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
----------|---------|----------|---------|---------|---------------------
All files | 60.34 | 50 | 100 | 60.34 |
index.js | 60.34 | 50 | 100 | 60.34 | 9,27,28,32-41,46-55
----------|---------|----------|---------|---------|---------------------
А если чуть поиграться с настройками не убирать из покрытия node_modules, то можно узнать, по каким node modules пробегается запрос:
-------------------------------------------------------------|---------|----------|---------|---------|-------------------------
File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
-------------------------------------------------------------|---------|----------|---------|---------|-------------------------
All files | 62.89 | 30 | 26.14 | 62.89 |
runtime-coverage-sample | 62.07 | 50 | 100 | 62.07 |
index.js | 62.07 | 50 | 100 | 62.07 | 9,20-28,32-41,54,55
runtime-coverage-sample/node_modules/content-type | 59.46 | 100 | 28.57 | 59.46 |
index.js | 59.46 | 100 | 28.57 | 59.46 | ...-122,126-163,174-190
runtime-coverage-sample/node_modules/debug/src | 42.57 | 0 | 14.29 | 42.57 |
debug.js | 42.57 | 0 | 14.29 | 42.57 | ...-166,176-189,199-202
runtime-coverage-sample/node_modules/etag | 95.42 | 100 | 75 | 95.42 |
index.js | 95.42 | 100 | 75 | 95.42 | 126-131
runtime-coverage-sample/node_modules/express/lib | 64.54 | 83.33 | 15.58 | 64.54 |
application.js | 62.11 | 50 | 20 | 62.11 | ...,618,628-631,638-644
express.js | 81.03 | 100 | 33.33 | 81.03 | 37-57,112
request.js | 76 | 100 | 0 | 76 | ...,496,507,508,519-525
response.js | 58.93 | 100 | 17.39 | 58.93 | ...,1016-1104,1118-1142
utils.js | 64.71 | 100 | 25 | 64.71 | ...-239,274-282,304-306
runtime-coverage-sample/node_modules/express/lib/middleware | 61.8 | 66.67 | 50 | 61.8 |
init.js | 69.05 | 50 | 50 | 69.05 | 29-41
query.js | 55.32 | 100 | 50 | 55.32 | 26-46
runtime-coverage-sample/node_modules/express/lib/router | 65.82 | 66.67 | 43.33 | 65.82 |
index.js | 61.93 | 100 | 42.11 | 61.93 | ...-635,640-648,651-662
layer.js | 74.59 | 100 | 40 | 74.59 | 33-50,63-74,166-181
route.js | 70.37 | 50 | 50 | 70.37 | ...8-90,171-189,193-215
runtime-coverage-sample/node_modules/finalhandler | 60.12 | 100 | 9.09 | 60.12 |
index.js | 60.12 | 100 | 9.09 | 60.12 | ...-259,272-311,321-331
runtime-coverage-sample/node_modules/fresh | 94.16 | 100 | 66.67 | 94.16 |
index.js | 94.16 | 100 | 66.67 | 94.16 | 94-101
runtime-coverage-sample/node_modules/mime | 62.96 | 25 | 33.33 | 62.96 |
mime.js | 62.96 | 25 | 33.33 | 62.96 | 4-10,22-37,49-63,79,80
runtime-coverage-sample/node_modules/parseurl | 87.34 | 33.33 | 75 | 87.34 |
index.js | 87.34 | 33.33 | 75 | 87.34 | 65-84
runtime-coverage-sample/node_modules/qs/lib | 33.05 | 17.65 | 18.75 | 33.05 |
parse.js | 41.32 | 100 | 33.33 | 41.32 | ...2-97,101-132,136-186
utils.js | 24.35 | 15.15 | 10 | 24.35 | ...,181-201,209-213,217
-------------------------------------------------------------|---------|----------|---------|---------|-------------------------
На всякий случай уточню ещё раз, что это минимальные примеры — без обработки ошибок, без чтения покрытия потоком (что тоже поддерживается), без какой-либо авторизации (вы явно не хотите, чтобы этот endpoint был публичен).
Как оно работает, и что вам сломает
Это параграф для любопытных. Вряд ли вас бует много, но всё же.
Фактически, я закинул в один котёл библиотеки collect-v8-coverage (простая библиотека для вызова профайлера), v8-to-istanbul, istanbul-lib-coverage, istanbul-lib-report, istanbul-reports — и довольно бысто начал получать примерно то что хотел. Единственная сложность возникла с тем, что V8 выдаёт полные данные о покрытии файла только если ты его грузишь уже после включения профайлера. Иначе удастся получить только данные о покрытиии вызванной функции — а на этом отчёта не построишь.
Но всё почти работало, поэтому я дописал хак — после получения фактического покрытия, мы отдельно получаем пустое, и накладываем одно на другое. Звучит довольно просто, но для получения этого самого пустого покрытия пришлось делать довольно стрёмные вещи:
1) Получаем список файлов, которые нас интересуют;
2) Включаем режим покрытия;
3) Перезагружаем require на прокси, с геттером, который рекурсивно выдаёт себя же;
4) Идём циклом по нужным файлам;
4.1) Запоминаем кеш require, после чего убираем его;
4.2) Грузим модуль, и пытаемся вызвать из него экспорты, если они функции, ловим и игнорируем все ошибки;
4.3) Возвращаем на место старый кеш require;
5) Возвращаем на место require;
6) Выключаем режим покрытия;
7) Получаем покрытие, и ставим вызов всех блоков в ноль.
В общем, звучит довольно стрёмно, но, поскольку перезагрузки выполняются синхронно и между ними ничего не может вклиниться — это более-менее безопасно — повторно загруженные ради покрытия файлы просто не смогут ничего сделать из-за фактически отключенных внешних библиотек. Разве что несколько кейсов я смог придумать:
- В случае использования глобальных переменных код таки сможет их вызвать.
- Могут остаться всякие демонические вещи вроде setInterval
- могут сыпаться ошибки вроде unhandledRejction, если есть кому их ловить.
В общем, вроде не слишком критично, но я бы рекомендовал убивать приложение после получения данных покрытия. Чисто от греха подальше. Ну или вместо forceReload
использовать forceLineMode
— тогда библиотека не занимается всей этой перегрузкой, и даст примерную оценку по строкам кода.
Если вдруг у кого есть мысли и знакомые люди, которые съели собаку на работе с покрытием (а не как я) — буду рад предложениям и пулл реквестам. Смайлик.