Всем привет!
Если вам всегда было любопытно, почему в nodejs функция require может вычислять
относительные пути относительно текущего файла, н-р require("../../lib");, добро пожаловать.
Недавно я описывал список правил для юниттестов на nodejs. И упомянул библиотеку rewire.
Во фреймворке glaceJS, который я развиваю на досуге, я предпочитаю указывать наиболее используемые функции и модули, такие как sinon и chai.expect, как глобальные, чтобы не заморачиваться каждый раз с их импортом.
Также я поступил и с rewire, тем более что в nodejs дефолтная функция загрузки require глобальная.
Вот тут меня поджидал неожиданный момент: в тесте код rewire("../../lib/config") упорно не хотел правильно резолвить относительный путь до модуля. Но если перед этим я явно импортировал var rewire = require("rewire"); внутри теста, то все работало замечательно. Стало понятно, что это последствия того, что rewire на самом деле импортится где-то в глубине фреймворка.
Заинтересовавшись сим прелюбопытным фактом, я отправился изучать исходники rewire и nodejs.
В rewire скрипт падал в rewire/rewire.js at master · jhnns/rewire · GitHub, т.к. nodejs кидал ошибку из node/module.js at main · nodejs/node · GitHub.
Развесив дебажные месседжы console.log я выявил первопричину, а заодно разобрался, как же у require получается правильно резолвить относительные пути относительно файла, где она вызывается.
При вызове require в функцию Module._resolveFilename попадает объект parent, представляющий собой nodejs-ый module.
У parent есть parent.paths (можете в коде любого своего скрипта вызвать module.paths и module.parent.paths и узнать, что это за пути такие), через которые идет попытка разрезовлить относительный путь до модуля.
При запуске nodejs-скрипта в коде доступен объект module, у которого есть module.parent и module.parent.parent и т.д. За тем, чтобы при вызове require был использован
правильный parent, следит сама nodejs. В случае rewire parent передается явно rewire/index.js at master · jhnns/rewire · GitHub, и его значение
будет зависеть от того, где был заимпорчен rewire.
Когда я импортил rewire где-то внутри фреймворка, а потом использовал его в другом модуле, в module.parent попадал совершенно не тот объект, относительно которого нужно было резолвить относительный путь.
Решением стала следующая идея: внутри своей функции rewire резолвить относительный путь относительно файла, где функция была вызвана, и передавать в оригинальный rewire уже абсолютный путь. К сожалению nodejs не позволяет определить путь к файлу, где была вызвана функция. Но к счастью, V8 API дает такую возможность. И даже есть проект caller-path. Но у меня он не заработал правильно, и поэтому я просто скопировал несколько строчек, которые мне были нужны, и подправил их до рабочего состояния, получив такой код:
var path = require("path");
var _rewire = require("rewire");
global.rewire = filename => {
var _ = Error.prepareStackTrace;
Error.prepareStackTrace = (_, stack) => stack;
var stack = new Error().stack.slice(1);
Error.prepareStackTrace = _;
var callerPath = stack[0].getFileName();
var callerDir = path.dirname(callerPath);
filename = path.resolve(callerDir, filename);
return _rewire(filename);
};
Теперь относительные пути определялись правильно. Однако выявилось другое неудобство, связанное уже непосредственно с rewire API, а именно с восстановлением оригинальных функций после теста.
Н-р в этом случае:
// my-module.js
var request = require("request");
exports.download = (fileUrl, filePath) => {
request.get(fileUrl, filePath);
};
Все делается просто, и оригиальные функции восстанавливаются через sandbox.restore():
var sinon = require("sinon");
var my = rewire("./my-module");
describe("my-module", () => {
var sandbox = sinon.createSandbox();
afterEach(() => {
sandbox.restore();
});
describe(".download()", () => {
beforeEach(() => {
var request = my.__get__("request");
sandbox.stub(request, "get");
});
it("should download", () => {
// some test
});
it ("shouldn't download", () => {
// some test
});
});
});
Но в случае:
// my-module.js
var get = (fileUrl, filePath) => {
if (!fileUrl.startsWith("https://")) {
throw new Error("Only https allowed!");
};
require("request").get(fileUrl, filePath);
};
exports.download = (fileUrl, filePath) => {
get(fileUrl, filePath);
};
Использовать sandbox.restore() не возможно, т.к. нет ссылки на неймспейс, через который можно обратиться к функции get и нельзя его застабить через sandbox (sandbox.stub(my, "get") даст ошибку, что my-module не имеет get).
Можно использовать rewire API и манипулировать с __get__, __set__ и запоминать оригинальную функцию, или же использовать rewire __with__. Но оба эти подхода выглядели очень громоздкими, а хотелось чего-то простого, как sandbox.restore(). Поэтому возникло решение, сделать дополнительный метод __reset__, который будет сбрасывать состояние модуля к оригинальному состоянию, и использоваться примерно так:
var sinon = require("sinon");
var my = rewire("./my-module");
describe("my-module", () => {
afterEach(() => {
my.__reset__();
});
describe(".download()", () => {
beforeEach(() => {
my.__set__("get", sinon.spy());
});
it("should download", () => {
// some test
});
it ("shouldn't download", () => {
// some test
});
});
});
И код rewire в таком случае приобрел вид:
var path = require("path");
var _rewire = require("rewire");
global.rewire = filename => {
var _ = Error.prepareStackTrace;
Error.prepareStackTrace = (_, stack) => stack;
var stack = new Error().stack.slice(1);
Error.prepareStackTrace = _;
var callerPath = stack[0].getFileName();
var callerDir = path.dirname(callerPath);
filename = path.resolve(callerDir, filename);
var mod = _rewire(filename);
var cache = {};
var set = mod.__set__;
mod.__set__ = function (name, stub) {
if (!Object.keys(cache).includes(name)) {
cache[name] = this.__get__(name);
};
set.call(this, name, stub);
};
mod.__reset__ = function () {
for (var [k, v] of Object.entries(cache)) {
this.__set__(k, v);
};
cache = {};
};
return mod;
};
Конец ![]()