Другими словами, как заглушить / шпионить за модулем, в котором экспортирована только одна функция?

// sum.js module
function sum(x, y) {
  return x + y;
}
module.exports = sum;

Допустим, указанная выше функция импортируется в файл js следующим образом:

// doStuff.js module
const sum = require('./sum');
function doStuff(x, y) {
  // do some stuff then ...
  return sum(x, y);
}
module.exports = doStuff;

Здесь нам нужно протестировать функцию doStuff, заглушив функцию sum.

Это возможно? Ну да. Однако это не так просто. Я пишу эту историю, чтобы объяснить, почему это непросто, и какие возможные варианты или инструменты мы можем использовать для этого.

Первое решение

>>>>>>>>

Если вы являетесь создателем этой функции sum, то простое решение здесь - изменить ее и изменить способ экспорта функции на одно из следующих решений:

module.exports.sum = sum;
// or
module.exports = { sum };
// or sometimes we might find it like
module.exports.default = sum;

Здесь мы меняем модуль sum, чтобы экспортировать объект, содержащий функцию sum.

Затем вы можете писать заглушки с помощью Sinon следующим образом:

const { assert } = require('chai');
const sinon = require('sinon');
const sumModule = require('./sum');
const doStuff = require('./doStuff');
sinon.stub(sumModule, 'sum').returns('fake sum')
const actual = doStuff(1, 2);
assert.equals(actual, 'fake sum');

Но что, если я не могу изменить модуль сумма? Что, если я установил эту служебную функцию из библиотеки с открытым исходным кодом, и вы не можете ее изменить? Что ж, вы можете подумать о том, чтобы внести свой вклад в библиотеку с открытым исходным кодом. Или…

Вот второй вариант, который вы можете сделать.

Второе решение

>>>>>>>>>>

Установите proxyquire или mock-require. Да, как вы уже слышали, вам нужно установить библиотеку только для того, чтобы заглушить экспортируемую функцию по умолчанию.

const proxyquire = require('proxyquire')
const sinon = require('sinon');
const sum = sinon.stub();
const moduleWithDependency = proxyquire('./doStuff’, {
  './sum': sum,
});
moduleWithDependency(1, 2);
sinon.assert.calledOnce(sum);

В том, что все? Нет. Есть еще одно решение - обойтись без дополнительных библиотек. Вот почему я пишу этот рассказ.

Третье решение

>>>>>>>>

Давайте сначала разберемся с проблемой, которую мы здесь пытаемся исправить. Узел импортирует модули один раз, а затем кеширует их. После кеширования он не импортирует их снова.

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

В начале любого JS файла напишите:

// module name: test-module.js
console.log('module loaded');
// some other code here

и потребовать этот файл дважды в другом файле.

require('./test-module');
require('./test-module');

Вы увидите, что консоль печатает «модуль загружен» только один раз, а не два раза.

Почему?

Так работает Node. После запуска приложения узел загружает экспортированные модули один раз и кэширует их в этом объекте module.cache. Здесь Node отслеживает все загруженные модули commonjs.

К счастью, этот объект доступен для записи, поэтому мы можем редактировать или удалять загруженные модули следующим образом:

delete module.cache[require.resolve(module-name)];

Вы можете проверить это, потребовав один и тот же модуль дважды снова, но на этот раз напишите оператор удаления посередине следующим образом

require('module-name');
delete module.cache[require.resolve('module-name')];
require('module-name');

На этот раз вы увидите, что консоль дважды напечатает «модуль загружен».

Какая связь между этой загрузкой модуля и заглушкой экспортируемой функции по умолчанию?

Node будет загружать и кэшировать экспортируемую функцию по умолчанию, поэтому никакая библиотека-заглушка, такая как sinon, не сможет подделать / шпионить за ней, если мы снова не перезагрузим модуль в объекте кеша. Как?

// First we need to remove the doStuff module
delete require.cache[require.resolve('./doStuff')];
// Second we need rewrite the cached sum module to be as follows:
require.cache[require.resolve('./sum')] = {
  exports: sinon.stub(),
};
// Third we need to require the doStuff module again
require('./doStuff');

Вот и все.

Но что, если функция, которую я тестирую, вызывает 2 или более функций по умолчанию из разных модулей? Я хочу заглушить все эти экспортируемые по умолчанию функции.

Что ж, в этом случае давайте перепишем три вышеупомянутые строки в функции, которая автоматизирует это для нас, следующим образом:

function fakeDefaultExport(moduleRelativePath, stubs) => {
  if (require.cache[require.resolve(moduleRelativePath)]) {
    delete require.cache[require.resolve(moduleRelativePath)];
  }
  Object.keys(stubs).forEach(dependencyRelativePath => {
    require.cache[require.resolve(dependencyRelativePath)] = {
      exports: stubs[dependencyRelativePath],
    };
  });
  
  return require(moduleRelativePath);
};

… И мы можем использовать его следующим образом:

const doStuff = fakeDefaultExport("./doStuff", {
  './sum': sinon.stub().returns('fake adding'),
  './sub': sinon.stub().returns('fake subtraction'),
});

Надеюсь, тебе понравится.