「テストが 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・system・tools・サンプリング設定を 1 つの JSON にまとめ、そのハッシュをキーにします。ここに入れ忘れた引数は、変えても同じ録画が返ります ——後の節で、私がこれに一度騙された話を書きました。
この3層が分離されていると、後からカセットフォーマットを変えたり、リクエスト一致の基準を緩めたりするときに、テスト側を書き換えずに済みます。
実装パターン①:Python での Messages API カセット
最も基本的な Messages API のカセット実装から始めます。anthropic の Python SDK を前提にしています。
# tests/cassette.py
"""Claude API のレスポンスを録画・再生するカセット実装。
本物の API を 1 回だけ呼び、以後はファイルから再生します。
差し替え先は `Anthropic().messages` ではなくリソースクラス
`anthropic.resources.messages.Messages` です(理由は直後の節に書きました)。
"""
import json
import hashlib
from pathlib import Path
from typing import Any
from contextlib import contextmanager
from unittest.mock import patch
from anthropic.resources.messages import Messages
from anthropic.types import Message
CASSETTE_DIR = Path( __file__ ).parent / "cassettes"
# キーに含める引数。ここに入れ忘れた引数は「変えても同じ録画が返る」ことになります。
KEYED_FIELDS = (
"messages" ,
"model" ,
"system" ,
"tools" ,
"tool_choice" ,
"max_tokens" ,
"temperature" ,
)
def _request_key (kwargs: dict[ str , Any]) -> str :
"""リクエストの内容から決定的なキーを生成します。"""
payload = json.dumps(
{k: kwargs.get(k) for k in KEYED_FIELDS },
sort_keys = True ,
ensure_ascii = False ,
default = str , # NotGiven 等の番兵オブジェクトで落ちないように
)
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": カセットが無ければ録画。あれば再生のみで、
未録画のリクエストが来たら AssertionError。
"new_episodes": カセットがあっても未録画のリクエストは追記録画。
"none": 録画禁止。録画が無ければ AssertionError で失敗。
"""
CASSETTE_DIR .mkdir( parents = True , exist_ok = True )
cassette_path = CASSETTE_DIR / f " { name } .json"
had_cassette = cassette_path.exists()
if had_cassette:
recordings: dict[ str , Any] = json.loads(
cassette_path.read_text( encoding = "utf-8" )
)
else :
recordings = {}
new_recordings: dict[ str , Any] = {}
real_create = Messages.create # 未束縛のまま退避する(self は呼び出し側から届きます)
def patched_create (self, ** kwargs):
key = _request_key(kwargs)
# 再生
if key in recordings:
return Message.model_validate(recordings[key])
# 録画を許さない条件を、モードごとに明示する
if record_mode == "none" or (record_mode == "once" and had_cassette):
raise AssertionError (
f "カセット { name } に key= { key } の録画がありません。"
f "ローカルで record_mode='new_episodes' を指定して録り足してください。"
)
response = real_create( self , ** kwargs)
new_recordings[key] = response.model_dump( mode = "json" )
return response
with patch.object(Messages, "create" , patched_create):
try :
yield
finally :
if new_recordings:
recordings.update(new_recordings)
cassette_path.write_text(
json.dumps(
recordings, ensure_ascii = False , indent = 2 , sort_keys = True
),
encoding = "utf-8" ,
)
差し替え先について、ひと言だけ補足させてください。私は最初、素直に Anthropic.messages.__class__ を patch.object の対象に渡しておりました。ところが手元で走らせると、テストは一行も実行されないまま AttributeError: <class 'functools.cached_property'> does not have the attribute 'create' で落ちます。SDK の Anthropic.messages はクラス属性として見ると functools.cached_property そのもので、create を持っているのはインスタンス化されたあとのリソースクラスのほうでした。リソースクラス Messages を直接差し替えると、どのクライアントインスタンスから呼ばれても等しく捕まえられます。
もうひとつ、退避した real_create を未束縛のまま 持つのが要点です。Anthropic().messages.create のように束縛済みメソッドを取ると、その一行のためだけにクライアントを作ることになり、API キーの無い CI で TypeError: Could not resolve authentication method. が出ます。再生しかしないはずの環境が、キーが無いという理由だけで赤くなるのは筋が通りません。
このカセットを使うテストは、初回実行時にだけ実 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 unittest.mock import patch
from pydantic import TypeAdapter
from anthropic.resources.messages import Messages
from anthropic.types import RawMessageStreamEvent
STREAM_CASSETTE_DIR = Path( __file__ ).parent / "cassettes" / "streams"
# RawMessageStreamEvent は Annotated Union なので、クラスメソッドの
# model_validate は生えていません。TypeAdapter を 1 つ作って使い回します。
EVENT_ADAPTER = TypeAdapter(RawMessageStreamEvent)
class CassetteStream :
"""録画されたイベント列を返すストリームです。
`with client.messages.stream(...) as s:` と同じ使い勝手にします。
"""
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 EVENT_ADAPTER .validate_python(ev)
@ property
def text_stream (self) -> Iterator[ str ]:
"""本物の MessageStream と同じく、本文デルタだけを流します。"""
for ev in self :
if ev.type == "content_block_delta" and ev.delta.type == "text_delta" :
yield ev.delta.text
@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 = Messages.stream # ここも未束縛のまま退避します
def patched_stream (self, ** kwargs):
if events:
return CassetteStream(events)
if record_mode == "none" :
raise AssertionError ( f "Stream カセット { name } が見つかりません。" )
original = real_stream( self , ** 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( EVENT_ADAPTER .dump_python(ev, mode = "json" ))
yield ev
return RecordingStream()
with patch.object(Messages, "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 " )
EVENT_ADAPTER を挟んでいるのは、型の都合です。RawMessageStreamEvent は RawMessageStartEvent から RawMessageStopEvent までを束ねた Annotated Union で、クラスではありません。RawMessageStreamEvent.model_validate(ev) と書くと AttributeError になります。TypeAdapter を 1 つ作っておけば、type の値を見て適切なイベント型へ振り分けてくれます。
このストリーミング版が入ると、SSE のイベント順序やデルタ累積が絡む処理——タイピング表示、トークン数の集計、途中キャンセル——を落ち着いて確かめられます。私はアプリのチャット画面のレンダリングでこれを使っていて、text_delta と input_json_delta が混ざるエッジケースを、実 API を叩かずに何度でも再現できるようになりました。
実装パターン③: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 のコストもレイテンシもゼロです。
私が一度だけ、緑のテストに騙された話
カセットを入れて数週間は快適でした。つまずいたのは、要約プロンプトの system を書き換えた日です。「出力は 3 行以内」という一文を足し、テストを走らせ、緑を確認してコミットしました。本番に出してみると、要約は 3 行に収まっておりません。
原因は Claude ではなく、私が書いた _request_key にありました。当時のキーは messages・model・tools の 3 つだけを見ています。system は含まれておりません。つまり system をどう書き換えてもキーは 1 ビットも変わらず、古い録画がそのまま返ってきます 。テストは、新しいプロンプトを一度も通していないのに緑でした。
手元で確かめたのがこちらです。
# 旧キー(messages + model + tools)と、system を含む新キーの比較
old = old_request_key(messages, model, tools = None )
new = _request_key({ "messages" : messages, "model" : model,
"system" : "簡潔に答えてください" , "max_tokens" : 64 })
# system を足す前後で、旧キーは一致してしまう → 古い録画を再生する
# 新キーは別の値になる → 未録画として AssertionError で止まる
カセットは「API を呼ばない」ための仕組みですから、キーに入れ忘れた引数は、テストの視界から丸ごと消えます。モックなら「モックを直し忘れた」と気づけますが、カセットは何食わぬ顔で古い応答を返すぶん、むしろ静かに裏切ります。
録画のキーは、応答を変えうる引数の一覧そのものです。 私はこの一件のあと、KEYED_FIELDS を定数として切り出し、SDK を上げたときに新しい引数が増えていないかを、この定数だけ見て点検するようにしております。system・tool_choice・max_tokens・temperature あたりは、忘れると必ず同じ形で返ってきます。
逆に、キーへ入れてはいけない引数もあります。metadata に実行 ID を入れているとキーが毎回変わり、カセットが一度も当たりません。「応答が変わるか」を基準に、入れる・入れないを一つずつ決めていくのが確実です。
なぜ「メッセージのハッシュ」をキーにするのか
リクエストキーの設計は、カセット方式の中で最も意見が分かれる部分です。私は最初、リクエストの順序だけで再生する「テープデッキ式」を実装しましたが、テストでリクエストを並べ替えたりリトライを入れたりすると壊れることが多く、すぐにハッシュキー方式に切り替えました。
ハッシュキー方式の利点は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 に置き、障害が出たら次の順で手元に引き寄せています。
障害 ID から R2 のキーを引く — 本番では各リクエストにトレース ID を付与し、requests/{trace_id}.json というキーで保存しておきます。
カセット形式に変換する — R2 から取った本番ペアを、テストで使うカセット JSON へ変換するスクリプトを通します。
手元で再現テストを走らせる — 変換したカセットを tests/cassettes/incidents/ に置き、対応する再現テストを書いて、--record を付けずに pytest を実行します(再生のみで走ります)。
この 3 つは順番に意味があります。1 を飛ばすと「どのリクエストが壊れたのか」が曖昧なまま 2 に進むことになり、結局は勘で選んだ 1 件を再現することになるためです。
# 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 ])
この仕組みが入ると、「本番でこの応答が壊れていた」と知らされてから手元で再現テストが書けるまでが、数分の作業になります。ログを眺めて原因を憶測する時間は、私の場合ほとんど消えました。
よくある間違いと落とし穴
カセット方式を入れた直後に、私がひととおり踏んだ落とし穴を 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 でバリデーションを緩める」ことです。緩めるほうを選ぶ場合は、壊れた録画に気づけなくなる分だけ、カセットを読む頻度を上げる必要があります。私は前者にしております。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" );
// Python 版と同じく、応答を変えうる引数はすべてキーに入れます
function requestKey ( params : MessageCreateParams ) : string {
const payload = JSON . stringify ({
messages: params.messages,
model: params.model,
system: params.system ?? null ,
tools: params.tools ?? [],
tool_choice: params.tool_choice ?? null ,
max_tokens: params.max_tokens,
temperature: params.temperature ?? null ,
});
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 > = {};
// API キーの無い CI でも生成できるよう、ダミーキーを許容します
// (再生モードでは 1 度もネットワークへ出ません)
const realClient = new Anthropic ({
apiKey: process.env. ANTHROPIC_API_KEY ?? "replay-only" ,
});
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 } の録画がありません` ,
);
}
if ( ! process.env. ANTHROPIC_API_KEY ) {
throw new Error ( "録画には ANTHROPIC_API_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 を、録画用に一枚ラップする層を足します。
Python 版と同じ考え方で system をキーへ入れている点だけ、移植のときに落とさないでいただければと思います。言語が変わっても、抜けたときの症状は同じです——テストは緑のまま、本番だけが変わります。
関連する Claude API の本番運用パターン
カセット方式は単独で機能しますが、他の本番パターンと組み合わせるとさらに強力になります。
レート制限や 529 エラーへの再試行戦略は、Claude API を本番で落とさないためのレジリエンス設計 に詳しくまとめています。リトライ後のレスポンスもカセット化すれば、リトライロジック自体のテストも決定的になります。
部分 JSON を逐次描画するような、イベント順序に強く依存する実装は ジェネレーティブ UI をストリーミングで描画する実装パターン と組み合わせると、描画側のロジックだけを録画再生で切り出して検証できます。
出力そのものの品質を測る話は プロンプトのゴールデンデータセットで回帰を検出する が近いところにあります。評価側の入力にカセット応答を流せば、評価ロジック自体の回帰も決定的に追えます。
受け渡すカセットを、読める状態で残す
カセットは Git に入る成果物ですから、他の人の目に触れます。受託でお預かりしたリポジトリに置く場合はなおさらです。私が決めているのは「1 テスト 1 ファイルに分ける」「JSON は必ず sort_keys=True で保存する」の 2 点です。キー順が安定していれば、録り直したときの差分が数行で収まり、レビューでも「何が変わったか」がそのまま読めます。
もうひとつは「カセットを読む時間を、テストを書く時間に含めておく」ことです。新しいカセットを録ったら、中を開くまではコミットしないようにしております。プロンプトに入れてはいけない情報が混ざっていないか、tool_use の input が想定どおりか、応答に誤りが残っていないか。ここを飛ばすと、誤った応答を「正解」として何ヶ月も再生し続けることになります。
長く動かすものほど、目視には限界があります。月に 1 度、全カセットを走査して「API キー風の文字列」「メールアドレス」「カード番号らしい数列」を拾うスクリプトを CI に置いておけば、見落としの最後の受け皿になります。私は Lab のサイト群でも、同じ発想の点検スクリプトを朝のタスクに載せて回しております。
並列実行とカセット衝突の回避
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 して差分を可視化します。
この差分検証は、system の更新やツール定義の変更が呼ぶ「意図しない振る舞いの変化」を見つけるのに効きます。私はプロンプトを触るコミットでは、必ずカセットの差分レポートを先に出し、それを読んでから本体を直す順番にしております。
たとえば「少し簡潔に」と意図して system を縮めたら、副作用としてコード例のコメントが日本語から英語に変わっていた——といった事象に、書き直しに入る前に気づけます。LLM 開発で最も怖い「プロンプトを少し変えたら別の場所が壊れる」現象を、定量的に追える仕組みです。
カセット方式が向かないケース
万能ではありません。3つのケースでは、カセット方式を避けるか別の手段と組み合わせるべきです。
1つ目は「Claude のモデル更新そのものを検証したいとき」です。 モデル名を claude-sonnet-4-6 から claude-opus-5 に載せ替えたときに応答品質がどう変わったかを評価したい場合、カセット方式は古い応答を返してしまうので使えません。このときは ゴールデンデータセットによる回帰検出 のような評価専用の仕組みで、実 API を叩いて比べます。
2つ目は「ストリーミング途中のキャンセル動作を検証したいとき」です。 カセットは「録画した全イベントを返す」前提なので、途中キャンセル時にサーバー側で何が起きるかは再現できません。このときはカセット方式と並行して、限定的な統合テスト(実 API を低頻度で叩く)を組み合わせます。
3つ目は「Claude の最新仕様変更に追従したいとき」です。 新しい stop_reason 値や新しいコンテンツブロック型が追加されたかどうかは、カセット内の応答を見ても分かりません。月次や四半期ごとに、代表的なテストを record_mode="new_episodes" で再録画して差分を確認する運用が必須です。
これらの限界を理解した上で組み合わせれば、カセット方式は CI コストを実質ゼロにしながら、本番品質のテスト網を維持する強力な武器になります。
次の一歩
まずは既存のテストを 1 つだけカセットにしてみていただければと思います。anthropic が入っているリポジトリに tests/cassette.py を置き、いちばん時間のかかっている統合テストを with cassette("my_test"): で囲んで、pytest --record を 1 度だけ走らせます。それだけで、そのテストの実行時間と料金はほぼ消えます。
そして録れたカセットを、コミットの前に一度だけ開いてご覧ください。私が緑のテストに騙されたのは、開かなかった日でした。