Mockанье зависимостей в node.js приложениях

    Mocks, fakes, and stubs — три столпа юнит тестирования. Конечно же все знают что это такое, как солить и когда есть. Я честно тоже так думал, пока не столкнулся с действительностью, под которую мне пришлось немного прогнуться.


    Все началось очень просто — я сменил место работы, и первое что я увидел в новой кодовой базе — это тесты, их было немного больше чем кода. И посреди этих тестов была странная конструкция


    Component = proxyquire.noCallThru().load(‘../Component’, {
 
         ‘../../core/selectors/common': { getData }

    }).default;

    Для справки:


    proxyquire — одна из самых популярных, и одна и самых старых библиотек для моканья зависимостей. Кроме нее сейчас на слуху inject-loader, rewire, mockery и другие. Под "старая" подразумевается, что рассчитана на "старый" node.js код. Никаких es6 imports.

    Для тех кто в танке, и не до конца понимает, что тут происходит — все очень просто. Proxyquire загрузит нам измененный ../Component, у которого один из импортов будет перекрыт нашим stubом.


    Зачем? Это отличный способ сделать юнит тест чуть более юнит, сведя количество неконтролируемых внешних зависимостей к нулю. Или просто облегчить тестирование временно убрав сложную, или не нужную логику из приложения.


    И все бы хорошо, только конкретно этот код — не работал. В имени файла была допущена ошибка, надо было еще на одну директорию вверх светить. А допущена ошибка была по еще более прозаической причине — сам проект использовал webpack-aliasы, адресуя все файлы от рута, а proxyquire работал с файлами после прохода по ним babel, где все пути были относительные.


    Представим что у вас есть код


     import something from 'something/else';

    Он вроде бы очень простой и понятный, только something на самом деле something2.default, а сам файл может быть его угодно — бабель содержит много магии.


    Именно так и началась эта эпопея. С попытки добавить поддержку алиасов в proxyquire.


    1. Решение №1. Первая встреча


    Первое решение, которое пришло в голову — добавить старому proxyquire немного мозгов. Его проблема вообще очень проста


    Proxyquire.prototype._require = function(module, stubs, path) {
      //…. 
      if (hasOwnProperty.call(stubs, path)) {
        var stub = stubs[path];

    Ему просто требуется точное соответствие имени подключаемого модуля и записи в списке на перегрузку.


    ./foo, ./foo.js, ../common/foo — это может и один файо, но три разных строки.

    За 5 минут в это место было добавлено немного мозгов, и еще через 5 минут на гитхабе на один PR стало больше. Еще через 5 минут он был безжалостно закрыт, с просьбой не добавлять излишние мозги сюда, а найти им другое место.


    2. Решение №2. Настоящие герои всегда идут в обход


    В принципе не только ребята из Proxyquire были не в восторге от решения. Я получил еще пару предложений из ближайшего окружения по альтернативным вариантам решения проблемы.


    Если кратко, то смысл был простой — достаточно написать простую функцию, которая из некого fileName1 создает некий fileName2, так чтобы первый был с алиасом, а второй уже был в “правильной” форме для proxyquire.


    Не пытаться изменить сам proxyquire, но написать что-то типа


    proxyquire.load(‘../Component’, addSomeMagic({
     'something/with/alias':{}
    })) 

    так, чтобы в сам proxyquire пришли правильные имена, без алиасов, но относительно исходному файлу.


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


    Component = proxyquire.noCallThru().load(‘../Component’, {

    Откуда proxyquire знает текущую директорию, и технически может загрузить файл относительно нее? И почему в тестах необходимо подключать proxyquire напрямую, или ничего работать не будет?


    Разгадка проста — proxyquire использует информацию, которую node.js передает ему через переменную module. В module.parent хранится информация о том кто первый открыл этот файл. Остается только не забыть себя стирать из кеша модульной системы, чтобы каждый раз был как… в первый раз.


    В общем разработка решения, которое бы не требовало модификаций proxyquire, ознакомило меня со многими ньюасами работы node.js.


    В итоге я так и не смог ничего сделать — нормальное решение требовало модификаций исходной библиотеки. Тупик.


    3. Решение №3. Мир не стоит на месте


    Параллельно с этим работа кипела. И, в попытках сделать себе жизнь чуть проще, я все пытался улучшить исходный proxyquire, так как от его использоваться отказаться было сложно. Я опять начал генерировать пул-реквесты.


    • Первый из них, который в принципе хоть как-то позволял расширять библиотеку (банально включил обратно наследование), был неожиданно быстро апрувлен.
    • Второй, который защищал “nonProjectFiles” от стирания из кеша, был так же шустро закрыт.
    • Третий, который добавлял так называемый “режим изоляции”, когда требовалось чтобы или все stubs были использованы(защищает от ошибок в их именах), или все зависимости были перекрыты (идеальный юнит тест в вакууме), висит без движения уже две недели :(

    Параллельно с этим, с целью опробовать решения на практике и вообще доказать их эффективность, велась работа над


    • proxyqure-2 — личным форком исходной библиотеки, в которой вмержены просто все желаемые фиксы,
    • proxyquire-webpack-alias — drop-in заменой proxyquire, с полной поддержкой webpack алиасов, без которых жизнь мне не мила. Одно плохо — для своей работы требует тот самый мой форк строчкой выше.
    • resolveQuire — та самая библиотекой из решения №2, которую я все-таки доделал до конца, и которая может работать на оригинальной proxyquire, и которое не смотря на свою “фунциональную” красоту, проигрывает таки двум верхним решения в некоторых моментах.

    Имхо, я могу как рекомендовать вам использовать что-либо из этого списка заместо proxyquire, так и просто ознакомиться с различиями в реализации, для лучшего понимания вопроса.


    4. Ретроспектива


    Как только я защитил перед коллегами свой вариант, а заодно вылечил не только свою, но и их давнюю боль, встал вопрос — а что дальше? Хотя лучше спросить — что раньше?


    Я пытался вспомнить как же мы мокали зависимости в моем прежнем месте работы. Это было сложно, потому что не мокали. Да и вообще опрос дельты окрестностей показал очень простую картину мира.


    Никто не мокает. sinon, fetch-mock не считается, это перехват не зависимости, а локальных или глобальных переменных.

    У нормальных людей — нормальный DI.


    Тогда я попробовал вспомнить как можно было вообще мокать зависимости в среде исполнения js кода на моем прежнем месте работы, где я провел лучшие годы своей молодости,


    • модульной системе ym и сборщике ymb.

    Для тех кто не знаком с этим очень интересным CommonJS/AMD совместимым решением, родом из Яндекс.Карт, есть парочка презентаций про нее.


    В общем там было все очень просто:


    // Определяем модуль А, зависящий от модуля Б
     module.define(‘a’,[‘b’], (provide,b) => {});
    
    // загружаем модуль А, Б загрузится по сети
     module.require(‘a’);

    А если нужно перекрыть зависимость


     module.define(‘b’,[], (provide) => {});
    // Определяем модуль А, зависящий от модуля Б
     module.define(‘a’,[‘b’], (provide,b) => {});
    
    // загружаем модуль А, Б уже есть, им и воспользуемся
     module.require(‘a’);

    Просто, и можно даже сказать — естественно.


    Как же работают различные решения для моканья файлов на родной для node.js модульной системе?


    • как inject-loader — webpack загрузчик, который физически меняет исходный файл так, как требуется. (+rewire)
    • как mockery — через перегрузку Module._load. Самого первого системного метода, который находится сразу за require.
    • как proxyquire — через перегрузку require.extensions handlers. Технически самого нижнего уровня.

    Разница очень проста:


    • inject-loader выдает реальный, но немного другой файл. Никаких патчей node.js
    • mockery работает “так высоко”, что результат ее работы не кешируется.
    • а вот proxyquire работает ниже кеша.
      Если вы в начале замокаете файл, а потом запросите его еще раз — получите свой мок. Лечиться через noPreserveCache, но кто об этом догадается?

    И везде есть некоторые ньюансы, реализации, которые немного усложняют жизнь:


    • inject-loader — работает только для непосредственных зависимостей.
    • mockery — каждый раз трет весь кеш начисто, что пагубно сказывается на скорости.
    • proxyquire — содержит очень много сюрпризов.

    5. Финальное решение


    Итак — у меня был свой proxyquire, знание дюжины других библиотек, анализ проблемы с разных сторон и хорошее понимание технической задачи. Цель была простая — one ring to rule them all.


    Как исходный вариант, максимально удовлетворяющий чувству прекрасного был взял mockery.
    Почему:


    • разделение определения перехватов и самого перехвата. registerMock/enable.
    • наличие режима изоляции. warnOnUnregistered/registerAllowable
    • наличие режима замены. registerAllowable

    Итогом стала библиотека rewiremock, которая удовлетворяет всем моим хотелкам, и из коробки может решать все задачи.


    import rewiremock from 'rewiremock';
     ...
    
     // totaly mock `fs` with your stub 
     rewiremock('fs')
        .with({
            readFile: yourFunction
        });
    
     // replace path, by other module 
     rewiremock('path')
        .by('path-mock');
    
     // replace default export of ES6 module 
     rewiremock('reactComponent')
        .withDefault(MockedComponent)
    
     // replace only part of some library and keep the rest 
     rewiremock('someLibrary')
        .callThought() 
        .with({
            onlyOneMethod
        })
    …
      rewiremock.enable();
      rewiremock.isolation();
      rewiremock.passBy(/node_modules/);
    
      // use native require
      const module = require(‘../core/somemodule’);
      // or add some magic….
      const module = require(rewiremock.resolve(‘core/module’));

    Она позволяет определять моки в отдельных файлах, "умно" трет кеш только для перекрытых файлов (и файлов которые их используют), поддерживает RegEx в passBy сетапе изоляции, имеет крайне простой api и всегда будет работать так как требуется.


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


    Описывать детали реализации думаю не стоит, все желающие могут сами покопаться к коде, или тестах, а заодно высказать все что угодно посредством комментариев.
    Или пулреквестов, обещаю прислушаться.


    https://github.com/theKashey/rewiremock


    Вот так одна маленькая заноза, просто не принятие “кривости” активно используемого инструмента, привело к открытию микрониши моков, сильно более глубокому пониманию вопроса, 4-ем новым библиотекам. И возможно к чуть чуть более лучшему миру.


    PS: Никаких претензий к авторам proxyquire нет. Почему? https://habrahabr.ru/post/328412/
    Поделиться публикацией
    Ой, у вас баннер убежал!

    Ну. И что?
    Реклама
    Комментарии 13
      +1
      Никто не мокает

      Jest мокает.
        0
        Я подразумевал живых людей из дельта окрестности. Библиотек то – на любой вкус.
          0
          И jest `мокает` так же как sinon. Это не перехват внутренней зависимости модуля, а внешней.
          Представим что вас есть код на реакте
           const someComponent = ()....
          
           const mapStateToProps = (state) => ({
             awesomeData: coolSelectorFromReduxStore
           });
          
           const ConnectedComponent = connect(connect(
            mapStateToProps
          )(someComponent);
          

          И вы тестируете именно ConnectedComponent. Ну бывает.
          Стандартные средства заставят вас собрать правильный store, обернуть все в Provider и все будет плохо. Там нечего мокать «напрямую». Все спрятано в файле и в тесты не светит.

          Rewiremock и компания же могут «по живому» перегрузить connect или coolSelectorFromReduxStore внутри файла, а не снаружи, и итоговый тест будет прост как три копейки.
          0
          Как будет выглядеть подобный пример, если заменить proxyquire на rewiremock?
          // test.js
          const proxyquire = require('proxyquire');
          const sinon = require('sinon');
          const assert = require('assert');
          
          const uptime = sinon.spy();
          
          proxyquire('./app', { process: { uptime } });
          
          const app = require('./app');
          assert(uptime.notCalled);
          app.uptime();
          assert(uptime.calledOnce);
          
          // app.js
          const process = require('process');
          module.exports = {
              uptime: process.uptime,
          };
          
            0
            У вас не совсем корректный пример. Точнее вы не правильно используете proxyquire.
            Ваш пример работает на основе паразитного сайд эффекта работы библиотеки, а точнее на основе странного поведения, когда после работы proxyquire в кеше модульной системы остается мокнутая версия process.
            Буквально в следущем тесте, когда вы уже будете ожидать «нормальное» поведение — вам прилетит сюрприз.
            Не делайте так. Очень много людей ночами не спали, все думали почему по одиночке их тесты работают, а все вместе — падают.
            Правильный код:
            const app = proxyquire.noPreserveCache().load('./app', { process: { uptime } }); // app с мокнутым process
            const app = require('./app'); // "настоящий" app, как и должно быть
            

            PS: В любом случае «нужный» app надо забирать из метода load, а не require. Даже если noPreserveCache вам не нужен.

            В то же время такой код нормален для rewiremock
            rewiremock('process').with({uptime});
            
            rewiremock.enable();
            const app = require('./app'); // app с мокнутым process
            rewiremock.disable(); // вычистит все затронутые модули
            
            const app = require('./app'); // "настоящий" app, как и должно быть
            
              +1
              Спасибо за доходчивое разъяснение. Насчет странного поведения, мне оно казалось вполне ожидаемым, точнее то что proxyquire заменяет кешированную версию модуля и если бы нужно было вернуть нормальное поведение я бы удалил модуль из кеша в соответствии с документацией https://nodejs.org/api/globals.html#globals_require_cache. Ваш вариант rewiremock с enable/disable действительно выглядит намного лучше и понятнее.
              В readme репозитория не помешал бы список публичных методов API с их кратким описанием, а в репозитории папка examples с простыми и самодостаточными примерами без лишнего кода. В остальном rewiremock выглядит приятно, возьму на вооружение.
            0
            промахнулся веткой
              –2
              Мока`ние чего во что? :)
                0

                Я так понимаю вот это мораль истории:


                У нормальных людей — нормальный DI.

                Но когда не до этого, то rewiremock может выручить. Правильно?

                  0
                  Все верно —
                  От каждого по способностям, каждому по потребностям.

                  Если нет способностей, но есть потребности — rewiremock и аналоги помогут.
                    0

                    Отправил вам PR. А как rewiremock будет работать с паралельными тестами (как в jest/ava)? Если я правильно понимаю то так же как и другие, т.е. лучше так не делать.

                      0
                      Жил в России — все фигели от моего русского. Переехал зарубеж — теперь все фигеют от моего английского.

                      Насчет параллельных тестов — сразу честно — не тестировал, но должно работать. Потому что:
                      1. База моков — общая (надо будет сделать простой интерфейс по клонированию и ресету моков перед началом теста).
                      2. По enable/disable из кеша вырезаются все мокнутые файлы и все файлы что как либо от них зависят.
                      Итого можно быть увереным, что после enable получите ровно то, что и нужно, а после disable — все вернется на круги своя.

                      Единственная проблема — ресет.
                      Возможно вот такой код сработает:
                      //mocks.js
                       rewireMock('file1'); // do nothing, just indicate mock, to wipe it from a cache
                       rewireMock('file2');
                      


                      //test.js
                       rewireMock('file1').with(something); // override mock.
                      
                        0
                        PS: Проблемы с общими моками, подключенными через третий файл — нет. У каждой инстанс сам за себя, так как rewiremock сам себя из кеша вырезает. Общий только кеш nodejs.

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

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