プロンプトを一行直したら、本番のレスポンスが別物になっていた
これは私が個人開発のサイドプロジェクトで実際に踏んだ事故です。商品レビューを Gemini で要約する関数のシステム指示を「箇条書きで 3 点」から「重要度順に 3 点」に変えただけで、出力に含まれる「総合評価」の一文が消え、リリース後しばらく気づかないまま静かに品質が落ちていました。Pytest なら回帰テストの記事はたくさんあるのに、TypeScript 側で同じことをやるための実装例が意外と見つからず、結局その場で書き起こすことになりました。
本稿はそのときの構成を整理したものです。Vitest と @google/generative-ai SDK をどう組み合わせれば、Gemini を呼ぶコードを安心して書き直せる土台ができるのか。Function Calling の検証やストリーミングのテストを、私が普段使っているパターンのままお見せします。
対象は TypeScript で Gemini API を触り始めて数週間〜数ヶ月、テストの土台を整えたい個人開発者の方を想定しています。チーム開発でも、最小構成の出発点として読んでいただけると思います。
なぜ Vitest を選ぶのか — Jest と比べた個人開発者目線
最初にここを書いておきます。Jest でも同じことはできます。ただ TypeScript と ESM 中心のプロジェクトでは、私は Vitest のほうを選びます。理由は三つあります。
まず、ESM サポートが素直です。@google/generative-ai を含む最近の SDK は ESM を前提に設計されていることが多く、Jest だと transform 設定で苦戦するケースがありますが、Vitest はほぼ素のまま動きます。次に、Vite ベースのプロジェクト(Astro、SvelteKit、Remix など)と設定を共有できます。最後に、起動が速く、テスト駆動でプロンプトを試行錯誤する作業に向いています。Gemini を呼ぶテストは比較的時間がかかるので、テストランナー側のオーバーヘッドが小さいのは地味に効きます。
逆に Jest を残す理由がある場面もあります。React Native や、既存の Jest 資産が大きいプロジェクトでは無理に乗り換える必要はありません。本稿のパターンはほぼそのまま Jest にも移植できますので、お手元の環境に合わせて読み替えてください。
セットアップ — 最小構成
まずは TypeScript プロジェクトに Vitest と SDK を入れます。
npm install -D vitest @vitest/coverage-v8
npm install @google/generative-ai
vitest.config.ts は最小構成で十分です。
// vitest.config.ts
import { defineConfig } from "vitest/config" ;
export default defineConfig ({
test: {
environment: "node" ,
globals: true ,
coverage: { provider: "v8" },
} ,
}) ;
API キーをテスト中に誤って本物の Gemini に投げないため、setupFiles で環境変数を上書きしておく運用にしています。
// tests/setup.ts
process.env. GOOGLE_GENERATIVE_AI_API_KEY = "test-key-not-real" ;
vitest.config.ts の test.setupFiles に ["tests/setup.ts"] を追加すれば、全テストでこの値が使われます。本物のキーを .env に置いていても、テストが先勝ちで上書きしてくれるので、レート制限を気にせず CI で回せます。
SDK 全体をモックする — 最初に書くべきテスト
「最初の一本」で確認したいのは、自作のラッパー関数が SDK を正しく呼び出していることです。レスポンス内容より先に、呼び出し側の組み立てを固定したい段階です。
ここで便利なのが vi.mock を使ったモジュールレベルのモックです。Gemini を呼ぶ薄いラッパー summarizeReview をテスト対象とします。
// src/summarize.ts
import { GoogleGenerativeAI } from "@google/generative-ai" ;
export async function summarizeReview ( text : string ) {
const genAI = new GoogleGenerativeAI (process.env. GOOGLE_GENERATIVE_AI_API_KEY ! );
const model = genAI. getGenerativeModel ({ model: "gemini-2.5-flash" });
const result = await model. generateContent ({
contents: [{ role: "user" , parts: [{ text }] }],
generationConfig: { temperature: 0.2 , maxOutputTokens: 200 },
});
return result.response. text ();
}
このラッパーに対するテストはこう書きます。
// tests/summarize.test.ts
import { describe, it, expect, vi, beforeEach } from "vitest" ;
const generateContent = vi. fn ();
vi. mock ( "@google/generative-ai" , () => {
return {
GoogleGenerativeAI: vi. fn (). mockImplementation (() => ({
getGenerativeModel : () => ({ generateContent }),
})),
};
});
import { summarizeReview } from "../src/summarize" ;
beforeEach (() => {
generateContent. mockReset ();
});
describe ( "summarizeReview" , () => {
it ( "temperature 0.2 とユーザーテキストを正しく渡す" , async () => {
generateContent. mockResolvedValue ({
response: { text : () => "1. 配送が早い \n 2. 説明と一致 \n 3. 価格が安い" },
});
const out = await summarizeReview ( "商品はとても良かったです。" );
expect (generateContent). toHaveBeenCalledTimes ( 1 );
const arg = generateContent.mock.calls[ 0 ][ 0 ];
expect (arg.generationConfig.temperature). toBe ( 0.2 );
expect (arg.contents[ 0 ].parts[ 0 ].text). toContain ( "商品はとても良かった" );
expect (out). toMatch ( /配送/ );
});
});
ポイントは、応答の文面そのものを expect で固定しないことです。文面は AI が決めるので、toMatch で「含むはず」のキーワードだけ確認します。固定したいのは「Gemini に何を送ったか」の側で、ここをスナップショット化していくのが、後で効いてきます。
Function Calling のテスト — 「呼ばれた道具」を契約として固定する
Function Calling を使うコードでは、モデルが選ぶ tool そのものをテストしたくなりますが、これはモデル次第なのでテストには向きません。代わりに、私は「アプリ側がツール定義を正しく組み立てているか」と「ツール応答後の続きが期待通りか」の二段で固定しています。
ツール定義側のテスト例です。実際にモデルを呼ばずに、ラッパーが組み立てる tools の中身だけ検証します。
// tests/tools.test.ts
import { describe, it, expect } from "vitest" ;
import { buildSearchTools } from "../src/tools" ;
describe ( "buildSearchTools" , () => {
it ( "検索ツールに必須プロパティ query を含む" , () => {
const tools = buildSearchTools ();
const fn = tools[ 0 ].functionDeclarations[ 0 ];
expect (fn.name). toBe ( "search_products" );
expect (fn.parameters.required). toContain ( "query" );
});
});
ツールが呼ばれた後の応答を検証する側は、generateContent のモックを段階的に返すように書きます。Gemini の Function Calling は「ツールを使え」と言われた最初の応答と、ツールの実行結果を渡した後の二回目の応答に分かれるので、mockResolvedValueOnce を 2 回チェーンするのが定石です。
generateContent
. mockResolvedValueOnce ({
response: {
functionCalls : () => [{ name: "search_products" , args: { query: "tシャツ" } }],
text : () => "" ,
},
})
. mockResolvedValueOnce ({
response: { text : () => "おすすめは Tシャツ A、B、C です。" , functionCalls : () => [] },
});
こうしておくと、モデルが本番で別の挙動をしても、ツールの組み立てとループ制御は壊れていないことが担保されます。プロンプト側の品質劣化と、コード側のバグを切り分けられるのが大きな利点です。
ストリーミング応答のテスト — AsyncIterable をモックする
generateContentStream を使うコードのテストでは、AsyncIterable を返すモックを書きます。最初に書いたとき意外とつまずいたので、テンプレートとして残しておきます。
async function* fakeStream ( chunks : string []) {
for ( const c of chunks) {
yield { text : () => c };
}
}
generateContentStream. mockResolvedValue ({
stream: fakeStream ([ "こん" , "にちは" , "、世界" ]),
});
呼び出し側でこれを for await で受けて結合する処理を書いていれば、結合結果が "こんにちは、世界" になるかをアサートできます。チャンクが途中で切れた場合の挙動を検証したいときは、yield の途中で throw する関数を別に用意して差し込むと、エラーハンドリングも一緒にテストできます。
スナップショットでプロンプトを「契約」として固定する
プロンプトのリグレッションを検知する一番楽な方法は、expect(prompt).toMatchSnapshot() でプロンプト文字列をスナップショット化することです。
import { describe, it, expect } from "vitest" ;
import { buildReviewPrompt } from "../src/prompt" ;
describe ( "buildReviewPrompt" , () => {
it ( "レビュー要約プロンプトの形を固定" , () => {
const p = buildReviewPrompt ({ productName: "Tシャツ A" , review: "良かった" });
expect (p). toMatchSnapshot ();
});
});
意図的にプロンプトを直したら vitest -u でスナップショットを更新します。意図せず変わったときは差分が PR に出るので、レビューで気づけます。プロンプトはコードと同じくらい「壊れたら困る資産」だという感覚を、チームに共有しやすくなります。
ここから先、出力品質まで踏み込みたい場合は、Pytest 側で同じ思想を扱った Gemini のプロンプトを壊さない — Pytest で組むプロンプト回帰テストの実践パターン で LLM-as-Judge と組み合わせる手法を紹介しています。Vitest 側でも同じ二段構えはそのまま再現できます。
CI で落とすか、警告に留めるか — 個人運用の落とし所
最後に運用側の話です。プロンプト関連のテストを CI で「赤」にすべきかどうかは、私の経験では分けて考えています。
ツールの組み立て、ストリーミングのチャンク結合、エラーハンドリングなど「コード側のバグ」を見るテストは、CI で赤にして止めます。ここは決定的なロジックなので、テストが落ちている時点でコードがおかしい可能性が高い、という前提で運用できます。
一方、プロンプトのスナップショットは、私は最初の数週間は「警告のみ」で運用しています。スナップショット更新の意図確認を PR レビューに任せ、CI を赤くしないことで、プロンプトを直したい衝動と CI 通過のストレスを切り離せます。安定してきたら徐々に厳しくする、という育て方が個人開発には合っています。
API モックも、最初は vi.mock で SDK 全体を置き換える方式(本稿の構成)で十分ですが、本番に近づくにつれ MSW で HTTP レイヤーをモックするほうが、SDK のバージョンアップに強くなります。プロジェクトの寿命に合わせて、徐々に重ね着していくのが現実的です。
テストデータは小さく、名前付きで持つ
地味ですが続けて効いた習慣を一つ残しておきます。テスト用の入力は、文字列をテストの中に直書きせず、名前付きのフィクスチャとして別ファイルに置いています。「日本語のレビューで誤字あり」「配送について苦情を含むレビュー」のような種類が三つ四つ増えてくると、スナップショットの差分を見ても「どのテストだっけ?」と一瞬で分からなくなるからです。
// tests/fixtures/reviews.ts
export const reviewWithDeliveryComplaint =
"商品はまあまあですが、送料を上乗せしたのに 2 週間届きませんでした。" ;
export const reviewWithMixedSentiment =
"素材は想像以上に良かったです。ただ、色味は写真と全然違いました。" ;
名前付きで import すれば、各 assertion がどのケースを見ているかが一目で分かります。疲れた夜にデバッグしているときに、この差は地味に効いてきます。「気になるエッジケース集」を少しずつ育てていくのも、ファイルが分かれていると続けやすくなります。
モック側も同じ発想で薄いヘルパーに切り出しておくと再利用が効きます。
function mockToolThenAnswer ( toolName : string , args : object , finalText : string ) {
generateContent
. mockResolvedValueOnce ({
response: { functionCalls : () => [{ name: toolName, args }], text : () => "" },
})
. mockResolvedValueOnce ({
response: { text : () => finalText, functionCalls : () => [] },
});
}
このような小さなヘルパーが三つ四つあれば、Gemini まわりの日常的なテストはだいたい書けます。派手な仕組みではありませんが、半年後の自分が戻ってきたくなるテストスイートにするには、こうした小さな積み上げが効くという感覚です。
レート制限とタイムアウトを「わざと起こして」テストする
本番で一番怖いのは、正常系ではなく 429(レート制限)や 503(一時的な過負荷)が返ってきたときの挙動です。私の運用では、ここを通らないコードは安心して夜間バッチに載せられません。generateContent のモックは成功値だけでなくエラーも返せるので、リトライ制御を決定的にテストできます。
まず、SDK が投げるエラーを模したオブジェクトを一度だけ返し、二回目で成功させます。
it ( "429 を一度受けたら一回だけリトライして成功する" , async () => {
const err = Object. assign ( new Error ( "Resource exhausted" ), { status: 429 });
generateContent
. mockRejectedValueOnce (err)
. mockResolvedValueOnce ({ response: { text : () => "ok" } });
const out = await summarizeReviewWithRetry ( "テスト入力" );
expect (generateContent). toHaveBeenCalledTimes ( 2 );
expect (out). toBe ( "ok" );
});
リトライ回数の上限も忘れずに固定します。無限リトライはレート制限を悪化させるだけなので、「3 回で諦めて投げ直す」ことをテストで保証しておきます。
it ( "3 回失敗したら諦めて例外を投げる" , async () => {
const err = Object. assign ( new Error ( "Service unavailable" ), { status: 503 });
generateContent. mockRejectedValue (err);
await expect ( summarizeReviewWithRetry ( "x" )).rejects. toThrow ( /unavailable/ );
expect (generateContent). toHaveBeenCalledTimes ( 3 );
});
タイムアウトは vi.useFakeTimers() と組み合わせると、実時間を待たずに検証できます。指数バックオフの待機を入れている場合、await vi.advanceTimersByTimeAsync(2000) のように時間を進めれば、2 秒のスリープを一瞬で飛ばせます。バックオフのテストで実時間を待つと、それだけでスイート全体が数十秒遅くなるので、ここは必ずフェイクタイマーにしています。
検証の観点を、私が実際にチェックリスト化しているものとして残しておきます。
1 回失敗 → リトライして成功すること(呼び出し回数 2)
上限まで失敗 → 例外を投げ、それ以上呼ばないこと(呼び出し回数 = 上限)
429 と 503 以外(例:400 のリクエスト不正)は即座に投げ、リトライしないこと
バックオフの待機時間がフェイクタイマーで飛ばせること
特に 3 番は見落としがちな落とし穴です。プロンプトの組み立てミスで 400 が返っているのに律儀にリトライしてしまうと、バグの発見が遅れます。「リトライしてよいエラーか」を判定する関数自体を独立したユニットテストで固定し、不要なリトライを回避しておくと安心です。私はこの 4 点をリトライ実装のレビュー観点として必ず確認するよう推奨しています。
どれくらい速くなったか — 自分の構成での実測
数字を一つ残しておきます。私が個人開発で運用しているレビュー要約まわりのテストは、現時点で 41 本あります。この全量を、実際の Gemini を一度も呼ばずにモックだけで回すと、手元の環境で 0.9 秒前後で完了します。仮に同じ 41 本を実 API で回したとすると、1 本あたり平均 1.5〜3 秒(モデルとプロンプト長による)かかるので、ざっと 60〜120 秒です。モックなら実 API のおよそ 70〜130 倍の速度で回せる計算になります。CI で毎 push 走らせることを考えると、この差は無視できません。
モックにするメリットは速度だけではありません。実 API に投げないので、CI のたびにトークンを消費しません。41 本 × 1 日 10 push と仮定すると、月あたり一万回以上の API 呼び出しをまるごと節約できる計算です。無料枠で運用している個人開発では、この「テストが課金やレート制限を一切踏まない」状態がそのまま精神的な余裕になります。実 API を踏まないモック構成は、個人開発で長く保守するテストとして強くお勧めします。
数字の出し方も書いておきます。Vitest はデフォルトで各ファイルの所要時間を出しますが、全体像は vitest run --reporter=verbose のほうが追いやすいです。遅いテストを見つけたら、私の場合たいていは「フェイクタイマーにし忘れたバックオフ待ち」か「不要に大きいフィクスチャ」のどちらかでした。前者は前節のとおり vi.useFakeTimers() で、後者はフィクスチャを必要最小限に削ることで、ほとんどのケースは 1 秒以内に戻せます。
まずは「呼び出しの形」だけでも固定してみる
ここまで読んでいただいてありがとうございます。テストは網羅性を上げ始めるとキリがないので、私が個人開発で繰り返し勧めているのは「最初の一本は、generateContent に何を渡しているかだけを固定する」という小さな出発点です。それさえ固まっていれば、プロンプトを試行錯誤しているときに「呼び出し側のバグなのか、プロンプトの問題なのか」を切り分けられます。
AI 固有の話ではありませんが、「テストで何を守りたいか」の感覚を整える一冊として、私自身も折に触れて読み返しています。