◆ ESM のローダーにフックを設定できる
◆ ソースコード上の指定とは別のモジュールをロードさせることもできる
  ◆ テスト時のモジュールのモックに使える

単体テストをするとき テスト対象の関数が中で呼び出す関数を別のものに置き換えたいことがよくあります
単純な計算で外部に依存せず高速なものならいいのですが ファイルやネットワークにアクセスしたりする場合は実際に呼び出すといろいろ面倒もあるので扱いやすいようダミーに置き換えたいです
置き換える関数はテスト用の実装にして 期待する引数が渡されたことを確認したり 固定でテスト用の値を返すようにするとはるかにテストしやすくなります

モックライブラリとしてそういうのがあるにはあるのですが 単純にオブジェクトのプロパティを置き換えるだけとかシンプルなものが多く モジュールの読み込み自体を置き換えることはできないことが多いです
Jest はモジュールのモックに対応してるのですが 重たいのでちょっとしたことで使う気が起きないです

Node.js 自体のモジュールローダーにカスタムフックを設定する機能があるので モジュールのロードを置き換えることを試してみました
ただ この機能は Experimental のステータスですし 他の Experimental より目立つ形で再設計中で変わる可能性ありと書かれています
変更履歴を見ると 16 と 18 で少し変わっていますが これを見る感じだと徐々に少しずつ変わってるくらいで 全く別物になるわけではなさそうです

ローダー

ESM で

import foo from "./foo.js"

と書くと同じフォルダの foo.js がロードされますが この挙動を変更できます

node --experimental-loader ./hook.js main.js

のように loader オプションでフックのスクリプトを指定します
import/require みたいに ここでは 「./」 をつけて明示的に相対パスで指定しないとカレントディレクトリを参照してくれないようです

このスクリプトでは resolve と loader という関数をエクスポートします
上の "./foo.js" みたいに from に指定した文字列からロード対象になる URL をつくるのが resolve 関数のすることです
resolve 関数で作った URL からモジュールのソースコードの文字列 (ArrayBuffer や TypedArray でもいい) を作るのが loader 関数のすることです
デフォルトの挙動に任せたければ 引数として受け取れる nextResolve や nextLoad を呼び出せば Node.js 側で処理してくれます
ミドルウェアの形式なので 書き換えたものを nextResolve に渡したり nextResolve から受け取った結果を書き換えて返すという使い方もできます

export const resolve = async (specifier, context, nextResolve) => {
const result = await nextResolve(specifier)
console.log(result)
return result
}

モック

ファイルへの書き込みをする関数を作って 実際に書き込む関数 (fs.writeFileSync) を置き換えるようにします
これだけだと fs を普通にロードして fs.writeFileSync を置き換えでもいいのですが ロードするだけで問題が起きるモジュールもあるのでそういうときに使えます

こんなモジュールを用意しました

[write.js]
import fs from "fs"

export const write = (text) => {
fs.writeFileSync("out.txt", [...text].join("\n"))
}

カレントディレクトリの out.txt に引数に渡した文字列を 1 文字ずつ改行して書き込みます
この関数をテストします

test("write", (t) => {
write("abc")
const log = getCallLog() // ここで writeFileSync を呼び出した引数を取得したい
assert.deepStrictEqual(log, [
["out.txt", "a\nb\nc"]
])
})

こういう感じで fs.writeFileSync を呼び出したときの引数をチェックしたいです

実行時のコマンドは モック用フックを指定してこうします

node --experimental-loader ./mock.js write.test.js

mock.js では fs のロード時に fs.mock.js をロードするようにして fs.mock.js の処理で fs.writeFileSync とその呼出結果を受け取れる関数を作ります

[mock.js]
export const resolve = (specifier, context, nextResolve) => {
if (specifier === "fs") {
return {
shortCircuit: true,
url: new URL("fs.mock.js", context.parentURL).href,
}
}

return nextResolve(specifier)
}

export const load = (url, context, nextLoad) => {
return nextLoad(url)
}

[fs.mock.js]
const log = []

const fs = {
writeFileSync: (...a) => {
log.push(a)
},
}

export default fs

export const getCallLog = () => {
return log
}

あとはテストするときに呼び出し時の引数を取得してチェックすれば完成です

[write.test.js]
import assert from "assert"
import { test } from "node:test"

import { getCallLog } from "./fs.mock.js"
import { write } from "./write.js"

test("write", (t) => {
write("abc")
const log = getCallLog()
assert.deepStrictEqual(log, [
[
"out.txt",
"a\nb\nc"
]
])
})

動的に

この方法で目的は果たせたものの フックをテストごとに用意するのは面倒です
.test.js のようなテストコード側で設定したいです

ローダーのフックも JavaScript のコードで 同じ Node.js プロセス内で動いているので mock.js モジュール内の変数を共有すればできるかと思ったのですが ファイルは同じでも別モジュールとして扱われるようでした
テストコードから mock.js をロードして モジュール内の変数を更新しても ローダー側では変化していないです

ですが グローバル変数は共有されるようだったのでグローバル変数経由でローダーの動作を設定するようしました

[mock.js]
globalThis.mockResolve = null
globalThis.mockLoad = null

export const resolve = async (specifier, context, nextResolve) => {
if (globalThis.mockResolve) {
return await globalThis.mockResolve(specifier, context, nextResolve)
} else {
return nextResolve(specifier)
}
}

export const load = async (url, context, nextLoad) => {
if (globalThis.mockResolve) {
return await globalThis.mockResolve(url, context, nextLoad)
} else {
return nextLoad(url)
}
}

[write.test.js]
import assert from "assert"
import { test } from "node:test"

import { getCallLog } from "./fs.mock.js"

globalThis.mockResolve = (specifier, context, nextResolve) => {
if (specifier === "fs") {
return {
shortCircuit: true,
url: new URL("fs.mock.js", context.parentURL).href,
}
}

return nextResolve(specifier)
}

const { write } = await import("./write.js")

test("write", (t) => {
write("abc")
const log = getCallLog()
assert.deepStrictEqual(log, [
[
"out.txt",
"a\nb\nc"
]
])
})

問題になるのは write.js をロードするタイミングで ローダーの設定が終わった後にロードする必要があります
そうしないと 本来の fs がロードされてしまいモックできません
そのために write.js のロードは動的に import() を使うようして globalThis.mockResolve の後にもってきてます

また fs みたいな他でも使いそうなモジュールだと 完全に置き換えてしまうと困る場合もあります
resolve 関数で受け取れる context の parentURL プロパティには import を行ったモジュールの URL が入ってるのでこれを見てモックモジュールに置き換えるか判断できます
テストでのみ使うなら specifier に 「?test」 みたいなのをつけてインポートすることで別モジュールとみなして resolve 関数内の置き換えを回避することもできます


一部不便なところはあるものの 思った以上にいい感じにできたので ローダーが正式に使えるようになったらモジュールのモックはこれでいいかなと思ってます