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

в 5:27, , рубрики: javascript, mock, node.js, proxyquire, stub, unit test, Программирование, Тестирование веб-сервисов, метки:

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/

Автор: kashey

Источник


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


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