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

в 11:22, , рубрики: javascript, node.js, V8, v8-profiler, покрытие кода, Тестирование веб-сервисов

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

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

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

Например:

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

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

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

Недавно я занимался написанием тест раннера для 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) Получаем покрытие, и ставим вызов всех блоков в ноль.

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

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

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

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

Автор: Джехи

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js