undici を v8 に上げたら MockAgent が効かなくなった — Node 内蔵 fetch と globalDispatcher symbol の罠

undici を v8 に上げたら MockAgent が効かなくなった — Node 内蔵 fetch と globalDispatcher symbol の罠

依存を更新する 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 が効きます。

コピペする前に知っておくと良いこと

一度踏むと忘れない類の罠

MockAgent を立てているのにテストが実ネットワークに抜ける」は、ぱっと見だと自分のモック設定ミスを疑ってしまい、原因にたどり着くまで時間を溶かしがちです。global fetch の裏に Node 内蔵 undici がいる、という一点を知っていれば、process.versions.undici を一発見るだけで切り分けが付きます。依存の major bump に紛れて出てくるので、undici を上げるときは「global fetch を使っているテストはないか」を一緒に思い出すのがおすすめです。

この記事をシェア

関連記事

記事一覧に戻る