Снимаем покрытие кода с уже запущенного Node.JS приложения

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

И снова я про тестирование и покрытие.

Наверное, вы уже поперхнулись кофе от вопроса “Зачем снимать покрытие с запущенного приложения” - но такая потребность периодически возникает.

Например:

  • Узнать покрытие интеграционных тестов без инстурментализации кода, завершения приложения и выгрузки репорта какими-то сторонними средствами;
  • Узнать без долгого ковыряния кода, по каким именно модулям приложения прошёл запрос;
  • Определить “мёртвый” код, который по факту не используется в приложении;
  • Узнать список транзитивных зависимостей, которые используются на определённые запросы.

Интересно? Поехали!

Откуда у вас такие картинки

Недавно я занимался написанием тест раннера для jest и mocha (кстати, в итоге вышло просто отлично), и узнал, что в V8 появилась возможность снимать покрытие без применения какой-либо дополнительной инструментализации. Это показалось мне безумно крутым - хоть часть оптимизаций выключается, но производительность кода падает не так сильно, как в случае прогона его через инструментализацию бабелем. Это значит, что мы можем в любой момент включать и выключать покрытие, и выгружать его чуть ли не с продакшн серверов!

Что получилось и как подключить

В общем, я закатал рукава и написал вот такую штуку.

Проще всего будет показать, как она работает, на простом примере с express.

  1. Подключаем библиотеку
1
const runtimeCoverage = require('runtime-coverage');
  1. Публикуем API endpoint, который включает покрытие:
1
2
3
4
app.get('/startCoverage', async (req, res) => {
await runtimeCoverage.startCoverage();
res.send('coverage started');
});
  1. Публикуем endpoint, который выдаёт данные покрытия
1
2
3
4
5
6
7
8
9
10
11
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 и получить в ответ

1
2
3
4
5
6
----------|---------|----------|---------|---------|---------------------
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 пробегается запрос:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
-------------------------------------------------------------|---------|----------|---------|---------|-------------------------
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
-------------------------------------------------------------|---------|----------|---------|---------|-------------------------

</spoiler>

На всякий случай уточню ещё раз, что это минимальные примеры - без обработки ошибок, без чтения покрытия потоком (что тоже поддерживается), без какой-либо авторизации (вы явно не хотите, чтобы этот endpoint был публичен).

Как оно работает, и что вам сломает

Это параграф для любопытных. Вряд ли вас бует много, но всё же.

Фактически, я закинул в один котёл библиотеки collect-v8-coverage (простая библиотека для вызова профайлера), v8-to-istanbul, istanbul-lib-coverage, istanbul-lib-report, istanbul-reports - и довольно бысто начал получать примерно то что хотел. Единственная сложность возникла с тем, что V8 выдаёт полные данные о покрытии файла только если ты его грузишь уже после включения профайлера. Иначе удастся получить только данные о покрытиии вызванной функции - а на этом отчёта не построишь.

Но всё почти работало, поэтому я дописал хак - после получения фактического покрытия, мы отдельно получаем пустое, и накладываем одно на другое. Звучит довольно просто, но для получения этого самого пустого покрытия пришлось делать довольно стрёмные вещи:

  1. Получаем список файлов, которые нас интересуют;
  2. Включаем режим покрытия;
  3. Перезагружаем require на прокси, с геттером, который рекурсивно выдаёт себя же;
  4. Идём циклом по нужным файлам;
    1. Запоминаем кеш require, после чего убираем его;
    1. Грузим модуль, и пытаемся вызвать из него экспорты, если они функции, ловим и игнорируем все ошибки;
    1. Возвращаем на место старый кеш require;
  5. Возвращаем на место require;
  6. Выключаем режим покрытия;
  7. Получаем покрытие, и ставим вызов всех блоков в ноль.

В общем, звучит довольно стрёмно, но, поскольку перезагрузки выполняются синхронно и между ними ничего не может вклиниться - это более-менее безопасно - повторно загруженные ради покрытия файлы просто не смогут ничего сделать из-за фактически отключенных внешних библиотек. Разве что несколько кейсов я смог придумать:

  1. В случае использования глобальных переменных код таки сможет их вызвать.
  2. Могут остаться всякие демонические вещи вроде setInterval
  3. могут сыпаться ошибки вроде unhandledRejction, если есть кому их ловить.

В общем, вроде не слишком критично, но я бы рекомендовал убивать приложение после получения данных покрытия. Чисто от греха подальше. Ну или вместо forceReload использовать forceLineMode - тогда библиотека не занимается всей этой перегрузкой, и даст примерную оценку по строкам кода.

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

Если вдруг у кого есть мысли и знакомые люди, которые съели собаку на работе с покрытием (а не как я) - буду рад предложениям и пулл реквестам. Смайлик.