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

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



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


    Например:


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

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


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


    Недавно я занимался написанием тест раннера для 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 — тогда библиотека не занимается всей этой перегрузкой, и даст примерную оценку по строкам кода.


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


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

    AdBlock похитил этот баннер, но баннеры не зубы — отрастут

    Подробнее
    Реклама

    Комментарии 0

    Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

    Самое читаемое