Node.js Test Runnerとビルトインモジュールのみでモジュールモックするヘルパーをつくった

## はじめに

以下の記事で、Node.js Test Runnerで、モジュールのサブセットをモックする方法を書きました。

https://qiita.com/ohakutsu/items/7145f083828df7f0e6e4

しかし、サブセットでないモジュールのモックもしたいときがあるなと思い調べたところ、モジュールモックをできる簡易的なヘルパーができたので紹介します。

なお、今回試すのはCJSです。

## ヘルパーを使ってテストする

サブセットでないモジュール（正しい名前がわからない）と言っているのは以下のようなものです。

```js:add.js
module.exports = (a, b) => {
  return a + b;
};
```

今回は、 [`node-fetch`](https://github.com/node-fetch/node-fetch) で試します。

テストしたいものは以下の`getExampleBody`関数です。

```js:index.js
const fetch = require("node-fetch");

exports.getExampleBody = async () => {
  const response = await fetch("https://example.com");
  const body = await response.text();
  return body;
};
```

この`getExampleBody`をテストする際に、`fetch`でのリクエストをさせたくないため、`fetch`関数をモックしたいです。

https://qiita.com/ohakutsu/items/7145f083828df7f0e6e4

の記事で書いたように、サブセットのモジュールは [`mock.method`](https://nodejs.org/api/test.html#mockmethodobject-methodname-implementation-options) でモックができるのですが、今回の場合はできません。

そこで思いついたのが、モジュールのロードに介入して、モック関数を返す方法です。
実際に書いたヘルパーは以下です。

```js:module-mock-helper.js
const Module = require("node:module");

const mocks = {};

const originalJsLoader = Module._extensions[".js"];
Module._extensions[".js"] = function (mod, filename) {
  if (mocks[filename]) {
    mod.exports = mocks[filename];
  } else {
    originalJsLoader(mod, filename);
  }
};

exports.mockModule = (name, fn) => {
  const filename = require.resolve(name);
  mocks[filename] = fn;
  delete require.cache[filename];

  return fn;
};
```

使い方は簡単で、`mockModule`の第一引数にモジュール名、第二引数にモック関数を渡すだけです。

このヘルパーを使って書いたテストコードは以下になります。

```js:index.test.js
const { test, mock } = require("node:test");
const assert = require("node:assert");
const { mockModule } = require("./module-mock-helper");
const fetch = require("node-fetch");

const mockFetch = mockModule(
  "node-fetch",
  mock.fn(fetch, async () => {
    return {
      text: async () => {
        return "sample body";
      },
    };
  }),
);

const { getExampleBody } = require("./index");

test("getExampleBody with mock", async () => {
  const body = await getExampleBody();
  assert.equal(body, "sample body");
  assert.equal(mockFetch.mock.callCount(), 1);
});
```

`mockModule`の仕組みは、モジュールのロードに介入し、モック関数を返すようにしています。

`mockModule`で渡されたモジュール名からパスを取得し、そのパスをkeyとして渡されたモック関数を記憶しておきます。
そして、`Module._extensions`をオーバーライドし、記憶しているモジュールをロードしようとした際にモック関数を返すようにしています。

## さいごに

無事、ビルトインモジュールだけでモジュールモックができました。
ただ、Native ESMの場合のモジュールロード周りがわからないので、次回挑戦するならESMで挑戦してみようと思います。

## Refs

- [Read Node.js Source Code to Deeply Understand the CJS Module System - Alibaba Cloud Community](https://www.alibabacloud.com/blog/read-node-js-source-code-to-deeply-understand-the-cjs-module-system_599765)
- [requireの仕組み - ぶれすとつーる](https://nazomikan.hateblo.jp/entry/2014/12/23/212741)
- [javascript - How to stub require() / expect calls to the "root" function of a module?- Stack Overflow](https://stackoverflow.com/questions/6997459/how-to-stub-require-expect-calls-to-the-root-function-of-a-module#answer-7037187)
- [Modules: CommonJS modules | Node.js v20.4.0 Documentation](https://nodejs.org/api/modules.html#requireresolverequest-options)
- [node/lib/internal/modules/cjs/loader.js at v20.4.0 · nodejs/node · GitHub](https://github.com/nodejs/node/blob/v20.4.0/lib/internal/modules/cjs/loader.js)
- [NodeJS Modules - How to Load a Module with require()](https://www.encora.com/insights/nodejs-how-to-load-a-module-with-require)
- [Node.js の require はキャッシュされるよ - 🐾 Nekonote](https://scrapbox.io/dojineko/Node.js_%E3%81%AE_require_%E3%81%AF%E3%82%AD%E3%83%A3%E3%83%83%E3%82%B7%E3%83%A5%E3%81%95%E3%82%8C%E3%82%8B%E3%82%88)