Typescriptのプロダクションコードでfs.existSyncを使うようにした。Node.jsのAPIなんでそれは当然動くんだけど、このコードをテストしたい。テストするために、fs.existSyncをsinonでモックしようとしたところ次のようなエラーになった。
existsSyncStub = sinon.stub(fs, "existsSync"); // TypeError: Descriptor for property existsSync is non-configurable and non-writable
non-configurable and non-writable とは?
Javascriptでは、writableとかconfigurableとかのデータプロパティを設定できるようになっており、それがfalseに設定されると代入不可や属性変更不可にするといった制御ができる。
fs.existsSyncはそういった設定がされているようだ。
これがなぜなのかというと、主としてセキュリティ観点と思われる。まぁ外部から容易にコアモジュール実装を入れ替えることが可能だと、それがセキュリティリスクになるよねというのもうなづける。
それでもモックしたい時の proxyquire
でもテストしたいし、テストするためにfs.existsSyncをモックしたい。そういう時にどうすれば良いのか調べたところ、proxyquireというモジュールを使うと良いらしい。実際、util.tsにあるhogeという関数をテストするために、fs.existsSyncをモックするコードは次のようになり、これでテストができるようになった。
const fsStub = { existsSync: sinon.stub(), }; const { hoge } = proxyquire("../util", { fs: fsStub }); suite("hoge テスト", () => { setup(() => { fsStub.existsSync.reset(); }); test("hoge〜〜", () => { const dirPath = "/absolute/path/to/dir"; fsStub.existsSync.withArgs(dirPath).returns(true); const result = hoge(dirPath) assert.strictEqual(result.path, dirPath); }); (略) });
proxyquireは何をしているの?
なんかすごく黒魔術的な感じがしたので、proxyquireが何をしているのか調べてみた。
結論から言うと、proxyquireはrequireをラップとして、モックしたいモジュールを差し替えるということをしている。
今回のケースで言うと、fsモジュールを require する際に、existsSyncに対してはスタブを差し込むようにしている。
Proxyquire.prototype._overrideExtensionHandlers = function (module, resolvedStubs) { /* eslint node/no-deprecated-api: [error, {ignoreGlobalItems: ["require.extensions"]}] */ var originalExtensions = {} var self = this Object.keys(require.extensions).forEach(function (extension) { // Store the original so we can restore it later if (!originalExtensions[extension]) { originalExtensions[extension] = require.extensions[extension] } // Override the default handler for the requested file extension require.extensions[extension] = function (module, filename) { // Override the require method for this module module.require = self._require.bind(self, module, resolvedStubs) return originalExtensions[extension](module, filename) } }) // Return a function that will undo what we just did return function () { Object.keys(originalExtensions).forEach(function (extension) { require.extensions[extension] = originalExtensions[extension] }) } }
require.extensionsと言うのが鍵で、このNode.jsのAPIは、元々は特定の拡張子のファイルがrequireされた時の挙動をカスタマイズするためのものとして用意されている。
Instruct require on how to handle certain file extensions.
proxyquire はこの機構を利用して、requireの挙動自体を書き換えており、モジュールをロードする際に、指定されたモックを差し込むようにしている。こうすると、確かに「書き換え」は発生しないので、冒頭のエラーも発生しない。裏技感ある。