依存を更新する PR で undici を ^7.28.0 から ^8.5.0 に上げたら、それまで緑だった CI が落ちました。失敗していたのは外部 API を叩くエージェント(Brave Search / Browserless)のテストで、ログを見ると MockAgent でモックしているはずのリクエストが 本物の API に到達して 422 / 401 を返している。コードのロジックは何も変えていないのに、です。
結論から言うと、Node の global fetch は Node に内蔵された undici が裏で動いていて、npm install した undici v8 で立てた MockAgent をそもそも見ていない、という罠でした。undici v7 のときは「たまたま」一致していたのが、v8 でズレて表面化したという話です。
なぜ undici v7 では動いて v8 で壊れたか
エージェント側は Node の global fetch を使っています(undici から fetch を import しているわけではない)。一方テストはこう書いていました。
import { MockAgent, setGlobalDispatcher } from "undici";
const mockAgent = new MockAgent();
mockAgent.disableNetConnect();
setGlobalDispatcher(mockAgent); // ← これで global fetch も差し替わるはず…?
ここで前提を確認すると、使っている環境は Node v24.12.0(内蔵 undici は 7.16.0) に対して、node_modules の undici は 8.5.0。両者はバージョンが違います。
$ node -p "process.versions.undici" # Node 内蔵 undici
7.16.0
$ node -p "require('undici/package.json').version" # npm 版 undici
8.5.0
global dispatcher は globalThis 上の Symbol.for('undici.globalDispatcher.X') を介して受け渡しされます。npm 版 undici v8 の setGlobalDispatcher は dispatcher を ...globalDispatcher.2 に書き込み、後方互換のため .1 側にもラッパー(Dispatcher1Wrapper)を置きます。ところが Node 内蔵の undici v7 が読むのは .1。global fetch はそのラッパー越しに MockAgent の intercept を経由せず、結局ネットワークに抜けてしまう。undici v7 同士なら symbol が .1 で一致していたので mock が効いていた、というわけです。
数行の再現コードで挙動が確認できます。
const { MockAgent, setGlobalDispatcher } = require("undici");
const ma = new MockAgent();
ma.disableNetConnect();
setGlobalDispatcher(ma);
ma.get("https://api.search.brave.com")
.intercept({ path: (p) => p.startsWith("/res/v1/web/search"), method: "GET" })
.reply(200, { mocked: true });
// global fetch はモックを無視して実ネットワークに抜ける(v8 + Node 内蔵 v7)
fetch("https://api.search.brave.com/res/v1/web/search?q=graphai")
.then((r) => r.json())
.then(console.log); // → { mocked: true } ではなく、本物の Brave のレスポンス
直し方:テストの中で global fetch を undici 経由にする
MockAgent 自体は v8 でも正しく動きます。問題は「Node の global fetch がそれを見ない」ことだけ。なので テストでモックが有効な間だけ globalThis.fetch を undici の fetch に差し替え、後始末で元に戻すのが最小の修正でした。エージェント本体は一切いじっていません。
import { MockAgent, setGlobalDispatcher, getGlobalDispatcher, fetch as undiciFetch } from "undici";
const originalDispatcher = getGlobalDispatcher();
const originalFetch = globalThis.fetch;
const mockAgent = new MockAgent();
mockAgent.disableNetConnect();
const setupEnvironment = () => {
setGlobalDispatcher(mockAgent);
// Node's global fetch is backed by Node's bundled undici, which ignores the
// MockAgent registered via the npm undici package (different globalDispatcher
// symbol), so route fetch through undici.
// @ts-expect-error undici's fetch type diverges nominally from Node's global fetch but is runtime-compatible.
globalThis.fetch = undiciFetch;
// …intercept の登録…
};
const cleanupEnvironment = () => {
setGlobalDispatcher(originalDispatcher);
globalThis.fetch = originalFetch; // 差し替えたままにしない
};
globalThis.fetch = undiciFetch で、エージェントが呼ぶ global fetch が「MockAgent を登録したのと同じ npm 版 undici v8」を通るようになり、intercept が効きます。
コピペする前に知っておくと良いこと
- 対象は「global
fetchを使うコード」だけ。最初からundiciのfetch/requestを import しているコードはsetGlobalDispatcherで素直にモックできるので、この差し替えは不要。実際、同じリポジトリの別エージェント(global fetch を使っていない)のテストは無修正で通っていました。 - 後始末で必ず元に戻す。
originalFetchを保存しておき、finally相当の後始末で復元しないと、差し替えた fetch が後続テストに漏れます。 @ts-expect-errorを 1 行入れているのは、undici のfetch型と Node の globalfetch型が名目的に非互換(Request/Responseの型が別物)だから。ランタイムでは互換なので、asキャストではなく理由付きの@ts-expect-errorで橋渡ししています。このリポジトリでは ESLint がsrcのみ対象でテストを lint しないため、ここで@ts-expect-errorを使っても引っかかりません。- 根っこは Node 内蔵 undici と npm 版のバージョン差。
process.versions.undiciとrequire('undici/package.json').versionがズレているほど、この手の「global fetch だけモックが効かない」が起きやすい、という当たりを付けておくと診断が速い。
一度踏むと忘れない類の罠
「MockAgent を立てているのにテストが実ネットワークに抜ける」は、ぱっと見だと自分のモック設定ミスを疑ってしまい、原因にたどり着くまで時間を溶かしがちです。global fetch の裏に Node 内蔵 undici がいる、という一点を知っていれば、process.versions.undici を一発見るだけで切り分けが付きます。依存の major bump に紛れて出てくるので、undici を上げるときは「global fetch を使っているテストはないか」を一緒に思い出すのがおすすめです。