「テストが LLM の気まぐれで毎回違う結果を返してきて、CI が落ちているのか本当に壊れているのか分からない」— Claude API を本番投入したチームの多くが、この壁にぶつかります。私もマルチエージェントの本番システムを運用していて、リファクタ後にテストが赤くなった原因を半日追いかけた末に「単に Claude が前回と少し違う言い回しをしただけだった」と気づいたことが何度もあります。
ここではRuby の VCR や Python の VCR.py、JavaScript の Polly.js が確立した「カセット(録画)方式」のテストパターンを、Claude API の Messages・Streaming・Tool Use の全シナリオに適用する設計と実装を扱います。単なる API モックではなく、本物の Claude 応答を一度だけ呼び出して保存し、CI ではそれを再生します。この仕組みが入ると、テストは決定的になり、CI コストはゼロに近づき、本番障害の再現も可能になります。
なぜ普通のモックでは足りないのか
Claude API のテストで最初に思いつくのは responses.create を unittest.mock で差し替える方法でしょう。私もそこから始めましたが、3 ヶ月運用して限界を感じました。理由は3つあります。
1つ目は「モックの応答が本物と乖離する」問題です。 Claude のレスポンス形式は頻繁に拡張されます。stop_reason の新しい値、content 配列の新ブロック型、usage オブジェクトのフィールド追加。手書きのモックは本物の応答を写し取っただけのスナップショットなので、SDK のバージョンアップや新機能リリースに追従できません。テストは緑のまま本番だけ壊れる、という最悪のパターンに陥ります。
2つ目は「Tool Use のフローが組み立てづらい」問題です。 Claude にツールを使わせるテストでは、API が「このツールを呼んでください」と返した後、ツール結果を渡してさらに API を呼ぶ、という最低2往復のシーケンスが発生します。これを手書きモックで組み立てると、tool_use_id の一致、role の順序、content ブロックの種類など、間違いやすい箇所が大量に出てきます。
3つ目は「Streaming の SSE イベント列を再現するのが面倒」です。 message_start、content_block_start、content_block_delta、content_block_stop、message_delta、message_stop を正しい順序で吐き出すモックを書くより、本物のストリームを録画してそのまま再生する方がはるかに信頼できます。
カセット方式は、これら全ての問題を「最初に1回だけ本物の API を叩いて、その応答を JSON または JSONL で保存し、テスト時はそれをそのまま返す」というシンプルな仕組みで解決します。
カセット方式の全体設計
実装に入る前に、設計の骨格を共有します。私が本番運用しているシステムでは、以下の3層構造で組み立てています。
第1層は「カセットストア」です。テストごとに固有の名前を持つ .json または .jsonl ファイルを tests/cassettes/ 配下に保存します。1テスト1カセットを徹底することで、どのテストが何を録画したかが grep で追えるようになります。
第2層は「インターセプター」です。anthropic.Anthropic クライアントの messages.create を実行時に差し替え、カセットが存在すれば再生、存在しなければ実 API を呼んで結果を保存します。record_mode というフラグで once(無ければ録画)、new_episodes(追加録画)、none(再生のみ。録画が無ければ失敗)の3モードを切り替えます。
第3層は「リクエストマッチャー」です。同じカセット内に複数のリクエストが録画されている場合、どの録画と現在のリクエストを照合するかのロジックです。messages 配列のハッシュ、model 名、tools の構成などを組み合わせてキーを作ります。
この3層が分離されていると、後からカセットフォーマットを変えたり、リクエスト一致の基準を緩めたりするときに、テスト側を書き換えずに済みます。
実装パターン①:Python での Messages API カセット
最も基本的な Messages API のカセット実装から始めます。anthropic の Python SDK を前提にしています。
# tests/cassette.py
"""Claude API のレスポンスを録画・再生するカセット実装。
本物の API を1回だけ呼び、以後はファイルから再生する。
record_mode="once" で初回のみ録画、それ以降は再生する設計。
"""
import json
import hashlib
import os
from pathlib import Path
from typing import Any
from contextlib import contextmanager
from unittest.mock import patch
from anthropic import Anthropic
from anthropic.types import Message
CASSETTE_DIR = Path( __file__ ).parent / "cassettes"
def _request_key (messages: list[ dict ], model: str , tools: list | None = None ) -> str :
"""リクエストの内容から決定的なキーを生成する。
同じカセット内で複数リクエストを区別するために使う。
"""
payload = json.dumps(
{ "messages" : messages, "model" : model, "tools" : tools or []},
sort_keys = True ,
ensure_ascii = False ,
)
return hashlib.sha256(payload.encode( "utf-8" )).hexdigest()[: 16 ]
@contextmanager
def cassette (name: str , record_mode: str = "once" ):
"""テストでカセットを使うためのコンテキストマネージャ。
使い方:
with cassette("test_basic_response"):
client = Anthropic()
res = client.messages.create(...)
record_mode:
"once": カセットが無ければ録画。あれば再生のみ。
"new_episodes": カセットがあっても新しいリクエストは追記録画。
"none": 録画禁止。録画が無ければ AssertionError で失敗。
"""
CASSETTE_DIR .mkdir( parents = True , exist_ok = True )
cassette_path = CASSETTE_DIR / f " { name } .json"
# 既存カセットを読み込み
if cassette_path.exists():
with cassette_path.open( "r" , encoding = "utf-8" ) as f:
recordings: dict[ str , Any] = json.load(f)
else :
recordings = {}
new_recordings: dict[ str , Any] = {}
real_create = Anthropic().messages.create
def patched_create (self, ** kwargs):
messages = kwargs.get( "messages" , [])
model = kwargs.get( "model" , "" )
tools = kwargs.get( "tools" )
key = _request_key(messages, model, tools)
# 再生モード
if key in recordings:
return Message.model_validate(recordings[key])
# 録画モード
if record_mode == "none" :
raise AssertionError (
f "カセット { name } に key= { key } の録画がありません。"
f "record_mode='once' で再録画してください。"
)
response = real_create( ** kwargs)
new_recordings[key] = response.model_dump( mode = "json" )
return response
with patch.object(
Anthropic.messages. __class__ , "create" , patched_create
):
try :
yield
finally :
if new_recordings and record_mode != "none" :
recordings.update(new_recordings)
with cassette_path.open( "w" , encoding = "utf-8" ) as f:
json.dump(recordings, f, ensure_ascii = False , indent = 2 )
このカセットを使うテストは、初回実行時にだけ実 API を叩き、2回目以降はファイルから再生します。
# tests/test_summarizer.py
"""録画されたレスポンスでロジックの回帰テストを行う例。"""
from anthropic import Anthropic
from src.summarizer import summarize_long_text
from tests.cassette import cassette
def test_summarizer_truncates_long_input ():
"""長文入力時に要約ロジックが期待通り動くことを検証する。"""
long_text = "Anthropic は 2026 年に..." * 100 # 実際の長文を想定
with cassette( "summarizer_long_input" ):
result = summarize_long_text(long_text)
# ロジック側の期待値を検証する(API 応答そのものではなく、
# アプリケーション層での処理結果を検証する点が重要)
assert len (result) < len (long_text) / 2
assert "Anthropic" in result
assert result.endswith( "。" )
ここで重要なのは「テストが検証しているのは API レスポンスではなく、アプリケーション層の振る舞い」という点です。Claude が今日と1ヶ月後に少し違う要約を返しても、テストはカセットに録画した1回分のレスポンスに対する処理を検証しているので決定的です。
実装パターン②:Streaming(SSE)応答のカセット
Streaming レスポンスは「イベントの時系列」なので、JSONL(1行1イベント)形式で保存するのが自然です。再生時は元のタイミングを再現する必要は無く、イベントを順番に yield するだけで済みます。
# tests/streaming_cassette.py
"""Streaming(SSE)レスポンスを JSONL で録画・再生する。
content_block_delta などのイベント順序を完全に再現できる。
"""
import json
from pathlib import Path
from contextlib import contextmanager
from typing import Iterator
from anthropic import Anthropic
from anthropic.types import RawMessageStreamEvent
STREAM_CASSETTE_DIR = Path( __file__ ).parent / "cassettes" / "streams"
class CassetteStream :
"""録画されたイベント列を返すストリームのモック。
本物の MessageStreamManager と同じインターフェースを提供する。
"""
def __init__ (self, events: list[ dict ]):
self ._events = events
def __enter__ (self):
return self
def __exit__ (self, * args):
return False
def __iter__ (self) -> Iterator[RawMessageStreamEvent]:
for ev in self ._events:
yield RawMessageStreamEvent.model_validate(ev)
@contextmanager
def stream_cassette (name: str , record_mode: str = "once" ):
"""Streaming API 用カセット。"""
STREAM_CASSETTE_DIR .mkdir( parents = True , exist_ok = True )
path = STREAM_CASSETTE_DIR / f " { name } .jsonl"
if path.exists():
events = [json.loads(line) for line in path.read_text( "utf-8" ).splitlines()]
else :
events = []
captured: list[ dict ] = []
real_stream = Anthropic().messages.stream
def patched_stream (self, ** kwargs):
if events:
return CassetteStream(events)
if record_mode == "none" :
raise AssertionError (
f "Stream カセット { name } が見つかりません。"
)
# 本物のストリームをラップして全イベントを保存しながら yield
original = real_stream( ** kwargs)
class RecordingStream :
def __enter__ (_self):
_self._inner = original. __enter__ ()
return _self
def __exit__ (_self, * a):
return _self._inner. __exit__ ( * a)
def __iter__ (_self):
for ev in _self._inner:
captured.append(ev.model_dump( mode = "json" ))
yield ev
return RecordingStream()
from unittest.mock import patch
with patch.object(
Anthropic.messages. __class__ , "stream" , patched_stream
):
try :
yield
finally :
if captured:
with path.open( "w" , encoding = "utf-8" ) as f:
for ev in captured:
f.write(json.dumps(ev, ensure_ascii = False ) + " \n " )
このストリーミング版を使うと、SSE のイベント順序やデルタ累積が絡む処理(タイピング表示、トークン数集計、途中キャンセル)を確実にテストできます。私はチャット UI のレンダリングテストでこれを使っていて、content_block_delta の text_delta と input_json_delta が混在するエッジケースを安全に再現できるようになりました。
実装パターン③:Tool Use の多段カセット
Tool Use のテストはカセット方式の真価が最も発揮されるシナリオです。1テスト中に「Claude が weather ツール呼び出しを返す → ツールを実行 → 結果を渡して再度 API を叩く → Claude が最終応答を返す」のような複数往復が発生しますが、カセット方式なら全往復を1ファイルに録画できます。
# tests/test_weather_agent.py
"""ツール呼び出しを含むエージェントの多段会話をカセットで検証する。
Claude が weather ツールを呼び、結果を受けて自然文を返すまでの全往復を録画する。
"""
from src.agents.weather_agent import WeatherAgent
from tests.cassette import cassette
def fake_get_weather (location: str ) -> dict :
"""テスト用のツール実装。本番のツールロジックをモックしない方針なら
本物の関数を呼んでも良いが、ここでは外部 API 依存を避けて固定値を返す。
"""
return { "location" : location, "temp_c" : 18 , "condition" : "晴れ" }
def test_weather_agent_handles_japanese_location ():
"""日本の地名でも正しくツール呼び出しと最終応答が成立することを検証。"""
with cassette( "weather_agent_kyoto" ):
agent = WeatherAgent( tool_impl = fake_get_weather)
reply = agent.ask( "京都の天気を教えてください" )
assert "晴れ" in reply
assert "京都" in reply
# ツールが正しく呼ばれたことの副次検証
assert agent.tool_call_count == 1
カセットには Claude が返した tool_use ブロックと、ツール結果を渡した後の最終応答の両方が記録されます。再生時は API 呼び出しがゼロになるので、CI のコストもレイテンシもゼロです。
なぜ「メッセージのハッシュ」をキーにするのか
リクエストキーの設計は、カセット方式の中で最も意見が分かれる部分です。私は最初、リクエストの順序だけで再生する「テープデッキ式」を実装しましたが、テストでリクエストを並べ替えたりリトライを入れたりすると壊れることが多く、すぐにハッシュキー方式に切り替えました。
ハッシュキー方式の利点は3つあります。順序非依存 (リトライや並列処理があっても動く)、部分的な追加録画が可能 (既存のカセットに新しいテストを足すだけで追記される)、デバッグ容易性 (カセットの JSON を直接開いて、どのキーがどの応答に対応するか一目で分かる)。
ただし注意点もあります。messages の中にタイムスタンプや UUID が含まれていると、毎回異なるハッシュが生成されてカセットが効きません。これを避けるには、_request_key 関数で正規化するか、テスト側で固定値を渡す設計にします。私は後者の方が事故が少ないと感じています。
CI への組み込みと「録画ガード」
カセット方式を CI に組み込むときの最大の論点は「CI 上で録画させない」ことです。CI 環境で録画が走ると、本番 API キーが漏れる、料金が膨らむ、テストが非決定的になる、という3重の問題が起きます。
これを防ぐパターンが「録画ガード」です。CI 上では record_mode="none" を強制し、録画が必要なテストは開発者がローカルで --record フラグを付けて実行する設計にします。
# tests/conftest.py
"""pytest の全体設定。CI 上では録画モードを禁止する。"""
import os
import pytest
def pytest_addoption (parser):
parser.addoption(
"--record" ,
action = "store_true" ,
default = False ,
help = "カセットを録画する(ローカル開発専用)" ,
)
@pytest.fixture
def record_mode (request):
"""テスト関数からこの fixture を受け取り、cassette() に渡す。
CI 上では強制的に 'none' になる。
"""
if os.getenv( "CI" ):
return "none"
return "once" if request.config.getoption( "--record" ) else "none"
# 使い方の例
def test_with_record_guard (record_mode):
with cassette( "my_test" , record_mode = record_mode):
# ローカルで --record 付きで1回実行 → カセット作成
# 以後 CI でも `record_mode='none'` で再生される
...
この設計が入ると、新しいテストを書いた開発者が pytest --record tests/test_new.py を1度だけ走らせて、生成されたカセットを Git にコミットすれば、CI ではゼロコストで永続的に動くテストが手に入ります。
本番障害の再現に使う応用パターン
カセット方式は「テスト用」だけのものではありません。本番障害が起きたとき、当該リクエストの内容を録画モードでローカル再現できるように設計しておくと、デバッグの速度が劇的に上がります。
私が運用しているシステムでは、本番のリクエスト/レスポンスペアを Cloudflare R2 に保存し、障害発生時は次のフローでデバッグできるようにしています。
第1ステップは「障害 ID から R2 のキーを引く」。本番では各リクエストにトレース ID を付与し、R2 のオブジェクトキーに requests/{trace_id}.json で保存します。
第2ステップは「カセット形式に変換」。R2 から取得した本番ペアを、テストで使うカセット JSON に変換するスクリプトを書きます。
第3ステップは「ローカルでのテスト実行」。変換したカセットを tests/cassettes/incidents/ に配置し、対応する障害再現テストを書いて pytest --record=none で実行します。
# scripts/replay_incident.py
"""本番障害の再現用スクリプト。trace_id から R2 のリクエストペアを引き、
カセット形式に変換してテスト用ディレクトリに配置する。
"""
import json
import sys
from pathlib import Path
import boto3
from tests.cassette import _request_key
INCIDENT_CASSETTE_DIR = Path( "tests/cassettes/incidents" )
R2_BUCKET = "claude-prod-traces"
def replay (trace_id: str ) -> Path:
"""trace_id から本番リクエストペアを取得し、カセットファイルとして保存する。"""
s3 = boto3.client(
"s3" ,
endpoint_url = "https://<account>.r2.cloudflarestorage.com" ,
)
obj = s3.get_object( Bucket = R2_BUCKET , Key = f "requests/ { trace_id } .json" )
pair = json.loads(obj[ "Body" ].read())
# pair = {"request": {...}, "response": {...}}
key = _request_key(
pair[ "request" ][ "messages" ],
pair[ "request" ][ "model" ],
pair[ "request" ].get( "tools" ),
)
INCIDENT_CASSETTE_DIR .mkdir( parents = True , exist_ok = True )
cassette_path = INCIDENT_CASSETTE_DIR / f " { trace_id } .json"
cassette_path.write_text(
json.dumps({key: pair[ "response" ]}, ensure_ascii = False , indent = 2 ),
encoding = "utf-8" ,
)
print ( f "✅ カセット作成: { cassette_path } " )
return cassette_path
if __name__ == "__main__" :
replay(sys.argv[ 1 ])
この仕組みを入れると、Slack で「本番でこの応答が壊れていた」と連絡を受けてから、ローカルで再現テストが書けるまで5分を切ります。本番ログだけを見て憶測で原因を探す時間がほぼゼロになりました。
よくある間違いと落とし穴
カセット方式を導入した直後にチームがハマりやすい問題を3つ挙げます。
1つ目は「カセットが肥大化してリポジトリが重くなる」問題です。 1テスト1カセットを徹底していても、Streaming のカセットは数百行になります。100テストあれば数 MB です。これを避けるには、tests/cassettes/ を Git LFS の管理下に置くか、もしくは gzip 圧縮した .json.gz で保存する設計にします。私は後者を選んでいて、json.dump の代わりに gzip.open で書き込むだけで容量が 1/10 程度になります。
2つ目は「カセットに API キーや個人情報が混入する」問題です。 リクエストの system プロンプトや messages に顧客データを渡しているテストがあると、その内容がそのままカセットに保存されます。Git にコミットすると情報漏洩です。これを避けるには、カセット保存前にサニタイズ層を挟みます。_request_key を計算した後、保存する前に messages の中の特定パターン(メールアドレス、API キー風の文字列、UUID)をマスクする関数を通します。
3つ目は「SDK のバージョンアップでカセットが壊れる」問題です。 Anthropic SDK のレスポンス型が拡張されると、古いカセットの Message.model_validate がフィールド不足で失敗することがあります。対策は2つで、ひとつは「SDK バージョンをカセットの _meta に記録しておき、バージョン不一致時に警告を出す」こと。もうひとつは「model_validate ではなく model_construct を使ってバリデーションを緩める」ことです。私は前者を採用していて、年に2〜3回ある SDK 大型アップデートのときに対象カセットだけ再録画するワークフローで運用しています。
TypeScript / Node.js での実装方針
ここまで Python での実装を扱ってきましたが、TypeScript でも基本構造は同じです。@anthropic-ai/sdk の Anthropic クラスを wrapper でラップし、messages.create の呼び出し前後にカセット読み書きを挟みます。
// tests/cassette.ts
/**
* Claude API カセット(TypeScript 版)
* Vitest と組み合わせて使う前提
*/
import fs from "node:fs" ;
import path from "node:path" ;
import crypto from "node:crypto" ;
import Anthropic from "@anthropic-ai/sdk" ;
import type { Message, MessageCreateParams } from "@anthropic-ai/sdk/resources/messages" ;
const CASSETTE_DIR = path. join (__dirname, "cassettes" );
function requestKey ( params : MessageCreateParams ) : string {
const payload = JSON . stringify ({
messages: params.messages,
model: params.model,
tools: params.tools ?? [],
});
return crypto. createHash ( "sha256" ). update (payload). digest ( "hex" ). slice ( 0 , 16 );
}
export async function withCassette < T >(
name : string ,
fn : ( client : Anthropic ) => Promise < T >,
recordMode : "once" | "none" = process.env. CI ? "none" : "once" ,
) : Promise < T > {
fs. mkdirSync ( CASSETTE_DIR , { recursive: true });
const cassettePath = path. join ( CASSETTE_DIR , `${ name }.json` );
const recordings : Record < string , Message > = fs. existsSync (cassettePath)
? JSON . parse (fs. readFileSync (cassettePath, "utf-8" ))
: {};
const newRecordings : Record < string , Message > = {};
const realClient = new Anthropic ();
const proxiedClient = new Proxy (realClient, {
get ( target , prop , receiver ) {
if (prop === "messages" ) {
return new Proxy (target.messages, {
get ( messagesTarget , messagesProp ) {
if (messagesProp === "create" ) {
return async ( params : MessageCreateParams ) => {
const key = requestKey (params);
if (recordings[key]) return recordings[key];
if (recordMode === "none" ) {
throw new Error (
`カセット ${ name } に key=${ key } の録画がありません` ,
);
}
const res = await messagesTarget. create (params);
newRecordings[key] = res as Message ;
return res;
};
}
return Reflect. get (messagesTarget, messagesProp);
},
});
}
return Reflect. get (target, prop, receiver);
},
});
try {
return await fn (proxiedClient);
} finally {
if (Object. keys (newRecordings). length > 0 && recordMode !== "none" ) {
fs. writeFileSync (
cassettePath,
JSON . stringify ({ ... recordings, ... newRecordings }, null , 2 ),
);
}
}
}
Node.js 環境では Proxy を使うと wrapper が薄くなり、SDK の他のメソッドへの影響を最小化できます。Streaming の場合は client.messages.stream() が返す AsyncIterator を録画用にラップする層を追加します。
関連する Claude API の本番運用パターン
カセット方式は単独で機能しますが、他の本番パターンと組み合わせるとさらに強力になります。
レート制限や 529 エラーへの再試行戦略は、Claude API レート制限と課金の本番完全ガイド に詳しくまとめています。リトライ後のレスポンスもカセット化すれば、リトライロジック自体のテストも決定的になります。
Streaming の中断・再接続パターンは Claude API Streaming のレジリエンス本番設計 と組み合わせると、ストリーム途中切断後の復旧フローを録画再生でテストできます。
LLM 出力の品質評価については LLM 評価パイプラインの構築ガイド と併用するのが有効です。評価パイプラインの入力にカセット応答を流せば、評価ロジック自体の回帰テストが可能になります。
LLM テストでカセット方式を採用するときも、Beck の「Triangulate」「Fake It」のリズムが本質的に効きます。
チーム開発でのカセット運用
複数人で同じカセットを編集する場面では、Git のマージコンフリクトが頻発します。私のチームで効果的だったのは「カセットファイルは1テスト1ファイルに分割し、JSON のキー順を必ずソート保存する」というルールです。同じテストを別の開発者が並行して再録画したときでも、ソート済みの JSON なら差分が小さく、コンフリクト解消が3分で済みます。
もうひとつ重要なのは「カセットレビューを PR チェックリストに入れる」ことです。新しいテストを追加する PR では、生成されたカセットの中身を必ずレビュアーが目視確認します。プロンプトに不適切な顧客データが混入していないか、Claude の応答に誤情報が含まれていないか、tool_use の input が想定通りかを見る習慣を作ると、テストの信頼性が大きく上がります。
特に長期運用するプロジェクトでは「カセット監査スクリプト」を定期実行する設計にしています。月1回、全カセットの中身をスキャンして「APIキー風の文字列」「メールアドレス」「クレジットカード番号らしい数列」を検出するスクリプトを CI に組み込んでおけば、人間のレビュー漏れを最後の砦で止められます。
並列実行とカセット衝突の回避
pytest-xdist で並列テストを走らせるとき、カセットの読み書きで競合する可能性があります。1テスト1カセットを徹底していれば書き込み競合は起きませんが、カセットの初回録画時に同じ親ディレクトリへ複数プロセスが書き込むと、ディレクトリ作成のレースで稀に失敗します。
対策はシンプルで、カセットディレクトリの作成は mkdir(parents=True, exist_ok=True) で冪等にしておき、書き込み自体はテンポラリファイル経由のアトミックリネーム(os.replace)で行います。
def _atomic_write_json (path: Path, data: dict ) -> None :
"""並列実行でも安全に JSON を保存する。"""
tmp = path.with_suffix(path.suffix + ".tmp" )
with tmp.open( "w" , encoding = "utf-8" ) as f:
json.dump(data, f, ensure_ascii = False , indent = 2 , sort_keys = True )
tmp.replace(path) # POSIX で原子的なリネーム
このパターンは Linux と macOS では完全にアトミックですが、Windows では rename 時に既存ファイルが消えていないとエラーになる場合があります。クロスプラットフォームで動かすなら os.replace を使い、書き込み先のディレクトリと同じファイルシステムにテンポラリを作るのが鉄則です。
プロンプトの回帰検出への応用
カセット方式は「プロンプトを少しいじったら出力がどう変わったか」を機械的に検出する用途にも使えます。具体的には、既存カセットを保ったままプロンプトを変更し、record_mode="new_episodes" で新しい応答を別キーで追記録画。両者を diff して差分を可視化します。
この差分検証は、システムプロンプトの更新やツール定義の変更による「意図しない振る舞い変化」を発見するのに非常に有効です。私のチームでは、プロンプトを変更する PR では必ず「カセット差分レポート」を CI で自動生成し、レビュアーが応答の変化を確認してから承認するワークフローを採用しています。
たとえば「丁寧さを少し下げて簡潔にする」という意図でシステムプロンプトを変えたら、副作用として「コード例の言語が日本語コメントから英語コメントに変わった」という事象を、人間がプロンプトを書き直す前に気づけるようになりました。LLM 開発で最も怖い「プロンプトを少し変えたら別の場所が壊れる」現象を、定量的に追える仕組みです。
カセット方式が向かないケース
万能ではありません。3つのケースでは、カセット方式を避けるか別の手段と組み合わせるべきです。
1つ目は「Claude のモデル更新そのものを検証したいとき」です。 モデル名を claude-opus-4-6 から claude-opus-4-7 に変えたときに、応答品質がどう変化したかを評価したい場合、カセット方式は古い応答を返してしまうので使えません。このときは LLM 評価パイプライン のような評価専用の仕組みで実 API を叩いて比較します。
2つ目は「ストリーミング途中のキャンセル動作を検証したいとき」です。 カセットは「録画した全イベントを返す」前提なので、途中キャンセル時にサーバー側で何が起きるかは再現できません。このときはカセット方式と並行して、限定的な統合テスト(実 API を低頻度で叩く)を組み合わせます。
3つ目は「Claude の最新仕様変更に追従したいとき」です。 新しい stop_reason 値や新しいコンテンツブロック型が追加されたかどうかは、カセット内の応答を見ても分かりません。月次や四半期ごとに、代表的なテストを record_mode="new_episodes" で再録画して差分を確認する運用が必須です。
これらの限界を理解した上で組み合わせれば、カセット方式は CI コストを実質ゼロにしながら、本番品質のテスト網を維持する強力な武器になります。
次の一歩
まずは1つの既存テストをカセット化してみてください。pip install anthropic 済みのリポジトリに tests/cassette.py を追加し、最も時間のかかっている統合テストを with cassette("my_test"): で囲んで pytest --record を1回走らせる。これだけで CI のそのテストの実行時間がほぼゼロになり、料金も発生しなくなります。最初の1テストで効果を体感したら、チームの中で「新しい LLM テストはカセット必須」というルールを作るのが定着の近道です。