Gemini API を呼ぶコードをテストしようとすると、最初の数分でつまずきます。生成 AI のレスポンスは毎回変わる ので、素直なアサーションが成立しないのです。
# これは毎回失敗しうる
assert response.text == "Pythonは汎用プログラミング言語です。"
最初に思いつくのは「API をモックにする」ですが、これは落とし穴です。モックのテストはモックの挙動を確かめているだけで、本番の API のレスポンス構造が実際に変わったときには沈黙します。私自身、個人開発で複数の自動化パイプラインを回していますが、モデル側の更新でレスポンスの一フィールドが静かに消え、夜間バッチが何日も無言で空振りしていたことがあります。あのときの「気づけなかった」という感覚が、この設計の出発点です。
ここで作るのは、構造を守るスナップショットテストを土台にしつつ、「構造は同じなのに中身が劣化する」回帰までを数値で捉える二層構成 です。さらに、それを毎回 API を叩かずに CI で回す方法、flaky(偶発的なゆらぎ)と本物の回帰を切り分けるしきい値、そしてスナップショットを事故なく更新する運用までを通しで設計します。
なぜスナップショットなのか — モックとの決定的な違い
スナップショットテストは「初回実行時に期待値を記録し、以後はそれと比較する」手法です。完全一致ではなく、構造と重要フィールドの一致 を見るのが要点です。
初回実行 → レスポンスを記録(スナップショット作成)
2回目以降 → 前回のスナップショットと比較し、差分があれば失敗
モックとの違いははっきりしています。モックは「自分が想定した形」を固定するので、現実の API がその想定から外れても気づけません。スナップショットは「実際に返ってきた形」を固定するので、現実が動いたときに差分として浮かび上がります。守りたいのは自分の想像ではなく、外部 API という動く現実のほうです。私はこの理由から、外部 API を伴うコードではモック一辺倒を避け、スナップショットを併用することを推奨しています。
Python では syrupy が定番です。
pip install syrupy pytest pytest-recording
SDK は新しい google-genai を前提にする
まず土台を新しい SDK に揃えます。古い google.generativeai ではなく、現行の google-genai(from google import genai)を使います。クライアントを一度作り、モデル名は gemini-2.5-flash のように明示します。
# gemini_client.py
from google import genai
from google.genai import types
client = genai.Client( api_key = "YOUR_GEMINI_API_KEY" )
def classify_sentiment (text: str ) -> str :
"""感情分類のJSONを返す薄いラッパー"""
resp = client.models.generate_content(
model = "gemini-2.5-flash" ,
contents = (
"次のテキストの感情を分析し、JSONで返してください。 \n "
'フィールド: sentiment (positive/negative/neutral), '
"confidence (0.0-1.0), explanation \n "
f "テキスト: { text } "
),
config = types.GenerateContentConfig(
temperature = 0 ,
response_mime_type = "application/json" ,
),
)
return resp.text
gemini-flash-latest のような浮動エイリアスはテスト対象には向きません。エイリアスは裏で別モデルに差し替わることがあり、テストが「いつの間にか別物を検証していた」状態になります。テストでは日付付き・世代付きのモデル名を固定 し、エイリアスの昇格は別途ゴールデンセットで判定する、という分離をお勧めします。
第一層:構造スナップショットで「形」を守る
テキスト内容は毎回変わってよいので、検証するのは構造とメタデータです。
# test_structure.py
import json
import pytest
from gemini_client import client
from google.genai import types
@pytest.mark.vcr # APIレスポンスを録画再生(後述)
def test_sentiment_schema (snapshot):
"""JSON出力のスキーマが維持されているかを検証する"""
resp = client.models.generate_content(
model = "gemini-2.5-flash" ,
contents = 'テキスト「この製品はとても気に入りました」の感情をJSONで。'
'フィールド: sentiment, confidence, explanation' ,
config = types.GenerateContentConfig(
temperature = 0 ,
response_mime_type = "application/json" ,
),
)
parsed = json.loads(resp.text)
# 中身ではなく「形」だけをスナップショット
schema = {
"has_sentiment" : "sentiment" in parsed,
"sentiment_valid" : parsed.get( "sentiment" ) in ( "positive" , "negative" , "neutral" ),
"confidence_is_float" : isinstance (parsed.get( "confidence" ), float ),
"has_explanation" : "explanation" in parsed,
"finish_reason" : str (resp.candidates[ 0 ].finish_reason),
}
assert schema == snapshot
初回は記録、以後は比較です。
pytest test_structure.py --snapshot-update # 初回:スナップショット作成
pytest test_structure.py # 以後:差分検証
temperature=0 でもテキストが揺れることがあります。これは内部サンプリングが完全な決定論ではないためで、だからこそ内容ではなく構造を固定する 設計が安定します。この第一層だけでも、「JSON のキーが減った」「finish_reason が MAX_TOKENS に化けた」といった構造の崩れは確実に捕まえられます。ここはエラーの早期検知として、本番運用に出す前の最後の砦になります。
第二層:embeddingで「意味の劣化」を数値にする
構造スナップショットには弱点があります。スキーマは正しいのに、中身の質が落ちる回帰 を見逃すのです。たとえば sentiment フィールドは存在し型も正しいが、分類の精度がモデル更新で落ちた、というケースです。形は守れても意味は守れません。この落とし穴を回避するため、第二層を重ねます。
代表入力に対する出力を embedding 化し、基準ベクトルとのコサイン類似度で「意味がどれだけずれたか」を数値で見ます。
# semantic_snapshot.py
import numpy as np
from google import genai
from google.genai import types
client = genai.Client( api_key = "YOUR_GEMINI_API_KEY" )
def embed (text: str ) -> np.ndarray:
r = client.models.embed_content(
model = "gemini-embedding-2" ,
contents = text,
config = types.EmbedContentConfig( output_dimensionality = 256 ),
)
v = np.array(r.embeddings[ 0 ].values, dtype = np.float32)
return v / np.linalg.norm(v) # 正規化してコサイン=内積に
def cosine (a: np.ndarray, b: np.ndarray) -> float :
return float (np.dot(a, b))
テスト側では、基準出力の embedding を baseline/ に保存しておき、現在の出力との類似度がしきい値を下回ったら失敗にします。
# test_semantic.py
import numpy as np
import pytest
from pathlib import Path
from gemini_client import classify_sentiment
from semantic_snapshot import embed, cosine
BASELINE = Path( "baseline/sentiment_review.npy" )
THRESHOLD = 0.88 # 経験的に決める(後述)
@pytest.mark.vcr
def test_sentiment_semantic_stability ():
current = classify_sentiment( "配送は遅かったが、サポートの対応は丁寧だった。" )
cur_vec = embed(current)
if not BASELINE .exists():
BASELINE .parent.mkdir( exist_ok = True )
np.save( BASELINE , cur_vec)
pytest.skip( "baselineを新規作成しました。次回から比較します。" )
base_vec = np.load( BASELINE )
sim = cosine(cur_vec, base_vec)
assert sim >= THRESHOLD , f "意味的にずれています: cosine= { sim :.3f } < { THRESHOLD } "
二層を重ねると、「形が壊れた」(第一層)と「意味が劣化した」(第二層)を別々のシグナルとして受け取れます 。原因の切り分けと対処が速くなり、モデル更新のたびに本文を目視する負担が大きく減ります。私の場合、代表入力 40 件に対する目視確認が 1 件 30 秒として約 20 分かかっていましたが、二層テストの実行(録画再生で約 12 秒)と失敗した数件だけの確認に置き換わり、確認作業を実測で約 95% 削減できました。
APIを毎回叩かない — CIを回すための録画再生
テストのたびに本物の API を叩くと、コストと時間、そしてレート制限が問題になります。pytest-recording(vcrpy)で、初回だけ本物を叩いて録画し、以後はカセットを再生します。
# pytest.ini
[pytest]
addopts = -- record-mode =none
# conftest.py — APIキーをカセットに残さない
import pytest
@pytest.fixture ( scope = "module" )
def vcr_config ():
return {
"filter_headers" : [( "x-goog-api-key" , "REDACTED" ),
( "authorization" , "REDACTED" )],
"filter_query_parameters" : [( "key" , "REDACTED" )],
"record_mode" : "none" ,
}
運用はこうします。
# 新しいテストを追加したとき:本物を1回だけ叩いて録画
pytest test_semantic.py --record-mode=once
git add tests/cassettes/ # カセットをコミット
# 以後のCI:ネットワークに出ずカセット再生のみ
pytest --record-mode=none
これで CI はオフラインで、毎回同じ入力に対して決定論的に走ります。APIキーや認証ヘッダーは必ず filter_headers でマスク してからコミットしてください。これを怠るとカセットに鍵が残ります。本番のキー漏洩を避けるための、地味ですが外せない対処です。
flakyと本物の回帰を切り分けるしきい値設計
意味的テストで一番難しいのは、しきい値です。厳しすぎると正常なゆらぎで赤くなり(flaky)、緩すぎると本物の劣化を見逃します。私はしきい値を勘で置かず、次の手順で決めることを推奨しています。
# calibrate_threshold.py — 自然なゆらぎの下限を測る
import numpy as np
from itertools import combinations
from gemini_client import classify_sentiment
from semantic_snapshot import embed, cosine
samples = [embed(classify_sentiment( "配送は遅かったが、対応は丁寧だった。" ))
for _ in range ( 20 )]
sims = [cosine(a, b) for a, b in combinations(samples, 2 )]
print ( f "min= { min (sims) :.3f } mean= { np.mean(sims) :.3f } p05= { np.percentile(sims, 5 ) :.3f } " )
手順は次の三つです。
モデルを固定したまま同じ入力を 20 回叩き、出力同士のコサイン類似度の分布(最小値・平均・p05)を取る
「自然なばらつき」の p05 より少し下、たとえば p05 が 0.91 ならしきい値を 0.88 前後に置く
それでも残る偶発的な flaky は、1回の失敗で落とさず「3回中2回失敗で回帰」と判定して回避する
こうすると、偶発的なゆらぎでは赤くならず、分布そのものがずれる本物の回帰でだけ赤くなります。最後の再試行ラッパーはこう書けます。
def stable_assert (check, attempts = 3 , need_fail = 2 ):
fails = sum ( 0 if check() else 1 for _ in range (attempts))
assert fails < need_fail, f " { attempts } 回中 { fails } 回失敗:回帰の可能性が高い"
Before / After:壊れやすいテストを構造監視に置き換える
最後に、典型的な「壊れやすいテスト」を、ここまでの設計で書き直します。
# Before:内容を完全一致で固定 → モデル更新のたびに無意味に赤くなる
def test_summary ():
out = summarize( "...長文..." )
assert out == "この記事はGeminiのテスト手法を解説しています。"
# After:構造(第一層)と意味(第二層)を分けて監視する
@pytest.mark.vcr
def test_summary_structure (snapshot):
out = summarize_json( "...長文..." ) # {"summary": str, "topics": [...]}
parsed = json.loads(out)
assert {
"has_summary" : isinstance (parsed.get( "summary" ), str ),
"topics_is_list" : isinstance (parsed.get( "topics" ), list ),
"topics_nonempty" : len (parsed.get( "topics" , [])) > 0 ,
} == snapshot
@pytest.mark.vcr
def test_summary_semantic ():
out = summarize_json( "...長文..." )
sim = cosine(embed(out), np.load( "baseline/summary.npy" ))
assert sim >= 0.86
Before は「正解の文字列」を人が決め打ちしているため、出力が少しでも変われば赤くなり、テストを直すために本質と関係ない作業が増えます。After は守るべきものを「形」と「意味」に分け、どちらが壊れたのかを切り分けられます。赤の意味が明確なテストだけが、長く保守され続けます。
スナップショット更新のガバナンス
スナップショットや baseline は、放っておくと「とりあえず --snapshot-update で緑にする」運用に流れます。これはテストを形骸化させる最短ルートです。更新には最低限のルールを置くことを強くお勧めします。
更新が正当なのは、(1) モデルやプロンプトを意図的に変えて構造を変えたとき、(2) 検証項目を追加・変更したとき、の二つだけです。モデル更新で勝手に中身が変わった場合は、更新ではなくまず原因の確認 です。更新時は必ず差分をレビューし、コミットを分けます。
pytest --snapshot-update
git add -p __snapshots__/ baseline/ # 差分を1つずつ目視してから載せる
git commit -m "test: update snapshots for prompt schema change (intentional)"
差分を本体コードの変更と同じコミットに混ぜないこと、コミットメッセージに「なぜ更新が正当か」を一文残すこと。この二つだけで、半年後の自分が「この緑は信用してよいのか」を判断できます。
どこから始めるか
最初から全部を二層化しようとせず、壊れたら痛い順に一層ずつ重ねるのが、続けられる現実的なやり方だと考えています。私が個人開発の現場で実際に踏んでいる順序はこうです。
いま最も壊れたら困る一本のレスポンスに、第一層の構造スナップショットを 1 つだけ足す
それが録画再生で安定して回るようになってから、代表入力に第二層の意味テストを重ねる
しきい値を実測でキャリブレーションし、flaky を再試行ラッパーで抑える
この順で進めると、テストが「増やしたのに信用できない」状態に陥らず、赤の一つひとつに意味が宿ります。お読みいただきありがとうございました。同じように非決定的な API と格闘している方の助けになれば幸いです。