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 は守るべきものを「形」と「意味」に分け、どちらが壊れたのかを切り分けられます。赤の意味が明確なテストだけが、長く保守され続けます。
カセットは過去の現実を固定する — 鮮度を保つ週次ライブスモーク
録画再生は CI を速く安くしますが、代償があります。カセットは録画した瞬間の API を保存したものなので、カセットが古くなるほど、CI は「過去の Gemini API」を検証し続ける ことになります。オフラインの CI が全部緑なのに、本番のバッチだけが壊れている——この状態は理屈のうえで成立してしまいます。
私自身、この構図に一度やられました。半年前のカセットで緑を維持していたリポジトリで、実際には応答フィールドの型が変わっていたのです。カセット再生のテストは最後まで何も言いませんでした。テストが嘘をついたのではなく、私が「いつの現実」を検証しているかを管理していなかった だけです。
対処はテストを二系統に分けることです。
ジョブ 実行契機 API 呼び出し 検証範囲 失敗時の扱い
オフライン CI PR ごと・push ごと なし(カセット再生) 構造+意味の両層 マージブロック
ライブスモーク 週1回・スケジュール あり(代表入力のみ) 構造のみ Issue 起票(ブロックしない)
ライブスモークで意味の層を回さないのは意図的です。本物を叩くと出力が毎回揺れるため、意味テストの赤は「モデルが変わった」のか「たまたまずれた」のか判別できません。週次で確かめたいのは一点だけ、カセットが記録している構造が、いまも現実と一致しているか です。
カセットの鮮度そのものもテストにしておきます。
# conftest.py に追記 — カセットの録画日を検査する
import time
from pathlib import Path
import pytest
CASSETTE_MAX_AGE_DAYS = 45
def pytest_sessionfinish (session, exitstatus):
"""カセットが古くなっていたら警告として出す(テストは落とさない)"""
stale = []
now = time.time()
for c in Path( "tests/cassettes" ).rglob( "*.yaml" ):
age_days = (now - c.stat().st_mtime) / 86400
if age_days > CASSETTE_MAX_AGE_DAYS :
stale.append((c.name, int (age_days)))
if stale:
print ( " \n [cassette] 鮮度切れのカセット:" )
for name, days in sorted (stale, key =lambda x: - x[ 1 ]):
print ( f " - { name } : { days } 日前の録画(上限 { CASSETTE_MAX_AGE_DAYS } 日)" )
落とさずに警告に留めているのは、鮮度切れそのものは不具合ではないからです。赤にしてしまうと、意味のない再録画で緑に戻す作業が習慣化します。ここで欲しいのは「そろそろ現実を見に行く時期です」という合図だけです。
週次ジョブ側は、構造テストだけを本物の API で回します。
# .github/workflows/live-smoke.yml
name : live-smoke
on :
schedule :
- cron : "0 21 * * 0" # 毎週月曜 6:00 JST
workflow_dispatch :
jobs :
smoke :
runs-on : ubuntu-latest
steps :
- uses : actions/checkout@v4
- uses : actions/setup-python@v5
with : { python-version : "3.12" }
- run : pip install -r requirements-dev.txt
- name : 構造テストのみ本物のAPIで実行
env :
GEMINI_API_KEY : ${{ secrets.GEMINI_API_KEY }}
run : pytest test_structure.py --record-mode=none --disable-recording -q
--disable-recording を付けると、@pytest.mark.vcr が付いたテストでもカセットを使わず実ネットワークに出ます。録画を上書きしないので、既存のカセットは汚れません。この一行があるかどうかで、週次ジョブが「確認」なのか「無自覚な再録画」なのかが変わります。
代表入力は 3〜5 件で十分です。ここは網羅ではなく、構造が動いたかどうかを最小コストで知るための定点観測です。私は月あたり数十円の API 費用で、カセットの嘘に半年気づかない事故を避けられるなら、迷わず払う側に立ちます。
赤くなった後の30秒 — フィールド単位で「どこがずれたか」を出す
意味テストが cosine=0.83 < 0.88 で落ちたとき、その一行から分かることはほとんどありません。出力全体を 1 本のベクトルに畳んでいるので、要約が劣化したのか、タグ配列が壊れたのかが、数値の中で潰れている からです。原因を探すために結局本文を目視することになり、二層テストを入れた意味が半分失われます。
そこで、JSON のフィールドごとに個別の baseline を持ち、失敗時に内訳を出します。
# field_drift.py
import json
import numpy as np
from pathlib import Path
from semantic_snapshot import embed, cosine
BASE_DIR = Path( "baseline/fields" )
def _to_text (value) -> str :
"""listやdictも安定した文字列に落として比較可能にする"""
if isinstance (value, str ):
return value
return json.dumps(value, ensure_ascii = False , sort_keys = True )
def field_drift (payload: dict , name: str ) -> dict[ str , float ]:
"""フィールドごとのコサイン類似度を返す。baseline未作成なら作って空を返す"""
out: dict[ str , float ] = {}
for key, value in payload.items():
path = BASE_DIR / name / f " { key } .npy"
vec = embed(_to_text(value))
if not path.exists():
path.parent.mkdir( parents = True , exist_ok = True )
np.save(path, vec)
continue
out[key] = cosine(vec, np.load(path))
return out
def format_report (drift: dict[ str , float ], threshold: float ) -> str :
rows = sorted (drift.items(), key =lambda kv: kv[ 1 ])
lines = [ f " { 'field' :<16 }{ 'cosine' :>8 } status" ]
for key, sim in rows:
mark = "DRIFT" if sim < threshold else "ok"
lines.append( f " { key :<16 }{ sim :>8.3f } { mark } " )
return " \n " .join(lines)
テスト側では、全体の類似度で判定しつつ、落ちたときだけ内訳をメッセージに載せます。
# test_semantic_fields.py
import json
import pytest
from gemini_client import classify_sentiment
from field_drift import field_drift, format_report
THRESHOLD = 0.88
@pytest.mark.vcr
def test_sentiment_fields_stable ():
payload = json.loads(
classify_sentiment( "配送は遅かったが、サポートの対応は丁寧だった。" )
)
drift = field_drift(payload, name = "sentiment_review" )
if not drift:
pytest.skip( "フィールドbaselineを新規作成しました。" )
worst_key = min (drift, key = drift.get)
assert drift[worst_key] >= THRESHOLD , (
f " \n 最も劣化したフィールド: { worst_key } (cosine= { drift[worst_key] :.3f } ) \n "
+ format_report(drift, THRESHOLD )
)
失敗するとこう出ます。
最も劣化したフィールド: explanation (cosine=0.804)
field cosine status
explanation 0.804 DRIFT
confidence 0.981 ok
sentiment 0.998 ok
sentiment と confidence は健全で explanation だけが崩れている、と即座に読めます。この形なら、原因の候補もその場で絞れます。分類そのものではなく説明文の生成が変わった、つまりプロンプトの説明部分か、モデルの出力長の傾向が動いた可能性が高い——という具合です。
内訳を出すようにしてから、私の手元では意味テストが赤くなったときの一次切り分けが、本文を読み比べる数分から、失敗ログを見るだけの十数秒に変わりました。テストの価値は赤くなることではなく、赤くなった直後に次の一手が決まること にあります。
なお、フィールドごとに baseline を持つと baseline/fields/ の中身は増えます。ここは代表入力を欲張らず、1 ユースケースにつき 1〜2 件に絞るのが続けるコツです。増やした分だけ更新レビューの負担が増え、次に述べるガバナンスが形骸化していきます。
スナップショット更新のガバナンス
スナップショットや 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 と格闘している方の助けになれば幸いです。