Всем привет!
Если вам всегда было любопытно, почему в 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;
};
Конец