朝のコーヒーが冷め始めた頃に GUI を作りこんでいて、ふと API 利用ダッシュボードを開いたら、まったく同じプロンプトを 80 回叩いた跡が残っていました。新しいローディング表示のリラックスした弾み方を確認したかっただけなのに、私の指は generateContent ボタンを 80 回押していたわけです。アーティスト・クリエイターの廣川政樹です。Dolice Labs の 4 サイトに加え別ジャンルのブログ 2 サイトを合わせて 6 サイトを個人で並行運営している関係で、Gemini API への「無意識の課金」を減らす仕組みづくりに、ここ数週間まとまった時間を投じています。
ローカルで UI を試すたびに API を叩く設計は、コードレビューの観点では当たり前のように見えます。けれども 6 サイトを横断して同じ問題を観察し続けると、UI 検証と評価検証が同じ API クライアントを共有しているせいで、本来なら 1 日 10 回で足りる「本物の呼び出し」が、UI 試行に紛れて 100 回以上発生しているのが分かりました。msw(Mock Service Worker)を fetch 層に挟み、HTTP フィクスチャを記録して再生する仕組みに作り直すと、見た目は何も変わらないのに API 呼び出しが静かになり、副作用としてストリーミング UI のバグが見つけやすくなりました。
このノートは「ローカルで UI を素早く回す」「フィクスチャを記録する」「再生時にも本物の挙動に近づける」「評価とフィクスチャを混同しない」という 4 つの軸で、私が 6 サイトに導入した設計と落とし穴を残しておくものです。
ローカルで本物の API を叩くと、何が壊れるのか
最初に整理したいのは「壊れるのは何か」です。本物の Gemini API を毎回叩いて UI を確認していると、表面上は何も壊れません。しかし、3 つの異なる層が静かに腐っていきます。
ひとつ目は イテレーション速度 です。私の壁紙アプリの管理画面では generateContent が約 1.4 秒、ストリーミング応答の完了までは平均 3.2 秒かかります。UI の余白を 4px 詰めるだけで毎回 3 秒待つのは、デザインの集中力をはっきり削ります。
ふたつ目は API クォータと課金 です。2014 年から個人開発を続けてきて、累計 5,000 万ダウンロードを超えるアプリ事業の収益を Lab 4 サイトのインフラ費用に回している身としては、AdMob から振り込まれた利益が無自覚な UI 試行で目減りするのは避けたいところです。Gemini 2.5 Flash は安価ですが、6 サイトの管理画面で UI を回せば月に数千円が「動作確認だけ」に消えます。
3 つ目が一番厄介で、UI のテストカバレッジが落ちる ことです。本物の API を叩くストリーミング UI は、毎回違うレスポンス長で返ってきます。ローディング状態・タイプライタ表示・途中キャンセル・エラーパスの 4 つを意図的に再現したいのに、本物では「いつもそれっぽく動く」状態になり、エッジケースが視界から外れていきます。
この 3 つを一度に解決する方法を 2 ヶ月ほど探しました。結論は「fetch を差し替えて固定レスポンスを返す」だけですが、固定レスポンスを「どう作り、どう保存し、いつ捨てるか」がすべてです。
msw を選んだ理由 — Service Worker と Node の二重対応
似たような目的のライブラリには nock(古い)、undici interceptor(ESM 周りで罠が多い)、miragejs(過剰な ORM 層を持つ)、自作 fetch ラッパー(既存コードへの侵襲が大きい)がありますが、私は msw に揃えました。
理由は 3 つあります。第一に、msw はブラウザ側では Service Worker、Node 側ではネイティブ fetch interceptor として動作するため、Next.js のクライアント/サーバー両側で同じハンドラを共有できます。第二に、既存コードに 1 行も触らずに導入できるので、6 サイトに横展開しても各サイトの本番コードが汚れません。第三に、ストリーミング応答(Server-Sent Events)を「ReadableStream を返すだけ」で扱えるので、Gemini の streamGenerateContent を素直にエミュレートできます。
ひとつだけ注意点があります。msw は Service Worker を public/mockServiceWorker.js に配置する必要があるため、Cloudflare Workers の wrangler dev では Service Worker が動きません。私は wrangler dev 系の検証は本物の Gemini API を叩く方針に切り替え、ローカル UI 検証だけ msw に乗せています。
ディレクトリ構成 — 6 サイト共通の fixtures モジュール
6 サイトに同じパターンを入れるとき、フィクスチャ自体をどこに置くかが運用の質を決めます。私は monorepo を採用していないので、git submodule + シンボリックリンクで共有しています。
~/repos/
├── claudelab.net/
│ └── _gemini_fixtures -> ../shared/_gemini_fixtures
├── gemilab.net/
│ └── _gemini_fixtures -> ../shared/_gemini_fixtures
├── rorklab.net/
│ └── _gemini_fixtures -> ../shared/_gemini_fixtures
└── shared/_gemini_fixtures/ ← git リポジトリ
├── handlers.ts
├── README.md
└── recordings/
├── ui/ ← UI 検証用(古くてよい)
│ ├── wallpaper-tag-suggest.sse.md
│ ├── review-reply-draft.json
│ └── ...
└── eval/ ← 評価用(モデル更新時に再記録)
├── 2026-05_gemini-2.5-flash/
└── 2026-05_gemini-3-pro/
ui/ と eval/ を分けるのが、後述する「フィクスチャの新鮮さポリシー」の前提になります。ui/ はレイアウト確認に使うので 1 年放置しても困りません。eval/ はモデル更新時に意味が変わるので、新モデルが出るたびに必ず取り直します。
ステップ 1 — record モード:本物の API を一度だけ叩く
最初に必要なのは、本物の API を叩いてレスポンスをフィクスチャに保存するモードです。環境変数 GEMINI_RECORD=1 のときだけ、fetch をラップしてレスポンスをディスクに書き出します。
// shared/_gemini_fixtures/recorder.ts
import { mkdir, writeFile } from "node:fs/promises" ;
import path from "node:path" ;
const FIXTURE_DIR = path. resolve (__dirname, "recordings" );
export function withRecorder ( fetchImpl : typeof fetch) : typeof fetch {
if (process.env. GEMINI_RECORD !== "1" ) return fetchImpl;
return async ( input , init ) => {
const url = typeof input === "string" ? input : input. toString ();
if ( ! url. includes ( "generativelanguage.googleapis.com" )) {
return fetchImpl (input, init);
}
const response = await fetchImpl (input, init);
const cloned = response. clone ();
const isSSE = response.headers. get ( "content-type" )?. includes ( "event-stream" );
const promptKey = await deriveKey (init?.body);
const target = path. join (
FIXTURE_DIR ,
isSSE ? `${ promptKey }.sse.md` : `${ promptKey }.json` ,
);
await mkdir (path. dirname (target), { recursive: true });
if (isSSE) {
const reader = cloned.body ! . getReader ();
const decoder = new TextDecoder ();
const chunks : string [] = [];
while ( true ) {
const { done , value } = await reader. read ();
if (done) break ;
chunks. push (decoder. decode (value));
}
await writeFile (target, formatSseRecording (chunks), "utf8" );
} else {
const text = await cloned. text ();
await writeFile (target, prettyJson (text), "utf8" );
}
return response;
};
}
ポイントは response.clone() で本物のレスポンスを消費せず、別ストリームから書き出すことです。これを怠ると UI 側で 2 度目の body.getReader() が空のストリームを掴んでしまいます。
deriveKey() はプロンプトとモデル名から短いハッシュを作る関数で、wallpaper-tag-suggest のような人間が読める名前を頭に付けます。フィクスチャを後から探すときに、ハッシュだけだと特定が困難になるためです。
ステップ 2 — msw ハンドラ:フィクスチャを再生する
記録が終わったら、GEMINI_RECORD を外してアプリを起動します。msw は自動的にフィクスチャを返すようハンドラを書きます。
// shared/_gemini_fixtures/handlers.ts
import { http, HttpResponse } from "msw" ;
import { readFile } from "node:fs/promises" ;
import path from "node:path" ;
const RECORDINGS = path. resolve (__dirname, "recordings/ui" );
export const handlers = [
http. post (
"https://generativelanguage.googleapis.com/v1beta/models/:model:generateContent" ,
async ({ request , params }) => {
const body = await request. json ();
const key = await deriveKey ( JSON . stringify (body));
const file = path. join ( RECORDINGS , `${ key }.json` );
try {
const text = await readFile (file, "utf8" );
return HttpResponse. json ( JSON . parse (text));
} catch {
return HttpResponse. json (
{ error: { message: `fixture not found: ${ file }` } },
{ status: 404 },
);
}
},
),
http. post (
"https://generativelanguage.googleapis.com/v1beta/models/:model:streamGenerateContent" ,
async ({ request }) => {
const body = await request. json ();
const key = await deriveKey ( JSON . stringify (body));
const file = path. join ( RECORDINGS , `${ key }.sse.md` );
const recording = await readFile (file, "utf8" );
return new HttpResponse ( replaySse (recording), {
headers: { "content-type" : "text/event-stream" },
});
},
),
];
replaySse() は記録した SSE を、本物のチャンクスピード(0.05〜0.15 秒間隔のランダム)で ReadableStream として返すユーティリティです。ここを「即時に全部返す」設計にすると、ストリーミング UI の検証が雑になるので意図的にスローダウンさせます。
function replaySse ( recording : string ) : ReadableStream < Uint8Array > {
const lines = recording. split ( / \r ? \n / );
const encoder = new TextEncoder ();
return new ReadableStream ({
async start ( controller ) {
for ( const line of lines) {
if ( ! line. startsWith ( "data:" )) continue ;
controller. enqueue (encoder. encode ( `${ line } \n\n ` ));
await new Promise (( r ) => setTimeout (r, 50 + Math. random () * 100 ));
}
controller. close ();
},
});
}
この 1 行のディレイがあるかないかで、タイプライタ表示のバグ検出率が桁違いに変わります。私はストリーミングの中断(ユーザーが Esc を押す)が再生時にも AbortController で止まることを確認するため、signal.aborted を毎チャンクの直前にチェックする実装にしました。
ステップ 3 — 構造化出力(responseMimeType: application/json)の差し替え
Gemini の構造化出力(responseMimeType: "application/json" + responseSchema)を多用していると、フィクスチャがそのまま JSON として読めるので扱いが楽です。ただし、record モードで保存した JSON にはスキーマの違反がある場合があり、それを「フィクスチャ側で正しく直して保存」する誘惑に勝てなくなることがあります。これをやり始めると、フィクスチャと本物の挙動が乖離していき、再生で UI が通っても本番で落ちるバグを生みます。
私の運用は「フィクスチャは本物の応答をそのまま保存する。スキーマ違反は本物の側で直す」です。バリデーション層を msw ハンドラの外側に置き、ハンドラはあくまで「本物がこう返した」だけを再現します。スキーマ違反のフィクスチャは Zod のエラーをそのまま UI に流して、エラー UI の見た目を検証するのに使えるので、捨てずに別ディレクトリへ移します。
recordings/ui/error/
├── schema-violation-missing-tags.json
├── empty-response-finish-reason-recitation.json
└── safety-blocked.json
エラーパス用フィクスチャを error/ に隔離しておくと、Storybook のような UI カタログから明示的に呼び出せます。
ステップ 4 — vitest で同じハンドラを共有する
UI 検証で使ったハンドラは、そのまま vitest からも呼べます。setupFiles で msw を起動し、テストごとに overrides() でエラーパスを差し込むと、テスト本体は読みやすいまま統合テストに近い領域までカバーできます。
// vitest.setup.ts
import { setupServer } from "msw/node" ;
import { handlers } from "@dolice/gemini-fixtures" ;
export const server = setupServer ( ... handlers);
beforeAll (() => server. listen ({ onUnhandledRequest: "error" }));
afterEach (() => server. resetHandlers ());
afterAll (() => server. close ());
onUnhandledRequest: "error" を必ず付けます。これがないと、ハンドラが書かれていない API 呼び出しを本物の Gemini に流してしまい、CI 上で 1 回テストを走らせるだけで数十円が消えます。実際私は最初のセットアップでこれを忘れ、PR のテストランナーで 4 サイト × 80 テストの想定外コールを発生させ、月末に Cloudflare のメールではなく Google Cloud Console のメールに驚いた経験があります。
エラーパスの差し込みは server.use() で局所的に。
import { server } from "../vitest.setup" ;
import { http, HttpResponse } from "msw" ;
it ( "safety フィルタで止まったとき、リトライ UI を出す" , async () => {
server. use (
http. post ( "**/streamGenerateContent" , () =>
HttpResponse. json (
{ promptFeedback: { blockReason: "SAFETY" } },
{ status: 200 },
),
),
);
// ...UI 側のアサーション
});
ステップ 5 — フィクスチャの「新鮮さ」ポリシー
フィクスチャは便利な分、放置すると本物との乖離が育ちます。私が決めた基準は次の通りです。
UI 用フィクスチャ(recordings/ui/) — 構造が変わらない限り 1 年放置可。レイアウト確認・タイプライタ・キャンセル・エラー UI の検証はこちらだけで足りる。
評価用フィクスチャ(recordings/eval/) — モデルが更新されたら、その日のうちに再記録。プロンプトの品質回帰テスト・JSON スキーマの実応答検証はこちらだけを正とする。
エラー用フィクスチャ(recordings/ui/error/) — finishReason: SAFETY, RECITATION, MAX_TOKENS, ネットワーク 5xx の最低 4 種類は常備。
この線引きを書面化していなかった頃、recordings/ui/ のフィクスチャを評価に使ってしまい、本番で gemini-3-pro に切り替えたら「ローカルで通っていた構造化出力が本番で壊れる」事故を起こしました。それ以来、評価用フィクスチャはモデル ID をディレクトリ名に必ず含めるルールにしています。
ステップ 6 — record モードを CI に流さない・本番ビルドに含めない
セキュリティと課金の両面で、record モードが意図せず動く事故を防ぐ仕組みも必要です。私は次の 3 段防御にしています。
GEMINI_RECORD=1 は .env.development.local のみ。.env.production には絶対書かない
package.json の prebuild で [ "$GEMINI_RECORD" != "1" ] を検証
GitHub Actions のワークフローで if env.GEMINI_RECORD == '1' を強制 false にする failsafe
それでも 1997 年に独学で CGI を書いていた頃のように、ある朝うっかり export GEMINI_RECORD=1 した shell から npm run build を呼んで、本番デプロイに record ロジックを含めてしまった、というヒヤリは私自身ありました。本番でフィクスチャを書き込もうとして Cloudflare Workers の fs エラーが出て、結果的に何も書き込まれなかったので無事でしたが、3 段防御の最後のひとつは「fs.writeFile が動かない実行環境を選ぶ」ことだと、このときに気付きました。
1 ヶ月運用した実測値 — 6 サイトでの圧縮効果
導入前と導入後で、Google Cloud Console の API メトリクスから抜き出した値です。
導入前(4 週間) : Gemini API 呼び出し合計 約 33,600 回/月、うち UI 試行と判定できる繰り返し呼び出しが 28,500 回(85%)
導入後(4 週間) : 合計 約 2,200 回/月、UI 試行系は 380 回(17%)
月額課金 (gemini-2.5-flash + gemini-3-pro 混在の概算): 約 18 ドルから 1.4 ドルへ
副次効果 : ストリーミング UI のバグ検出が 1 ヶ月で 9 件→23 件に増加(タイプライタ表示の競合、AbortController の漏れ、空応答 UI など)
数字だけ見ると地味ですが、6 サイト分の月額 18 ドル → 1.4 ドルは、私の感覚では「月 1 杯のコーヒー代を浮かせるかどうか」ではなく「Lab 系サイトの開発ループそのものの集中力が変わったかどうか」のほうが大きい変化でした。3 秒待つ習慣を捨てると、デザインの細部に踏み込む回数が増えます。
次に試すなら — 最小構成スターター
今から導入するなら、次の最小構成から始めるのが私の推奨です。
msw と @mswjs/data をインストール(後者は将来的にフィクスチャを動的生成するときに便利です)
recordings/ui/ 配下に 3 ファイルだけ手で書いた JSON を置く(最初は record モードを書かなくて構いません)
handlers.ts で 1 つの URL パターンだけ差し替える(generateContent のみ)
ストリーミング応答は最初は無視して、構造化出力のみフィクスチャ化する
ここまでで動いたら、その日の UI 試行回数を計測してから record モードを実装する
最初から完成形を目指すと挫折しやすいので、私は 5 ステップに分けて 6 サイトに段階的に入れました。最後の 2 サイトは構造化出力しか使っていないので、SSE 再生も書かずに済んでいます。
宮大工だった祖父たちが「手を動かして直すこと自体が信心」と言っていたのを、コードのリファクタリングのたびに思い出します。フィクスチャを書く作業は地味ですが、6 サイトを 1 人で回す体力を守るための、私にとっての小さな信心のような時間になりました。同じように複数プロジェクトで Gemini を呼んでいる方の参考になれば嬉しいです。