Claude API を本番プロダクトに組み込んだ瞬間、開発者は従来のソフトウェアテストでは経験しない壁にぶつかります。同じプロンプトを送っても返ってくる文章が毎回微妙に異なるのです。「昨日通っていたテストが今日は落ちている」——この現象に対して expect(response).toBe(exactString) で立ち向かうのは、風に向かって定規を当てるようなものです。
問題の本質は「AI の出力は確率的である」という一点に集約されます。ただ、確率的だからテストできないわけではありません。テスト対象を「出力の文字列そのもの」から「出力が満たすべき性質」にシフトすれば、堅牢なテストスイートは構築できます。
4つの AI 搭載サイトを運用するなかで固まってきた、Claude API アプリケーション固有のテスト設計パターンをまとめます。ユニットテストから E2E テストまで、各レイヤーで「何を」「どうやって」テストするのかを、Vitest + TypeScript の動作するコード例とともに示していきます。
AI アプリケーションのテストが従来のテストと根本的に違う3つの点
従来のソフトウェアテストでは、入力と出力の関係が決定的(deterministic)です。add(2, 3) は常に 5 を返します。ところが Claude API を使ったアプリケーションでは、3つの不確定要素が加わります。
1. 出力の非決定性 : 同一のプロンプトでも、temperature が 0 でない限り出力は毎回異なります。temperature: 0 に設定しても、モデルのアップデートで出力が変わることがあります。
2. 出力形式の揺れ : JSON を返すよう指示しても、余分な説明文が付加されたり、キー名が微妙に変わることがあります。Structured Output(tool_use)を使えばスキーマレベルでは安定しますが、値の内容は依然として揺れます。
3. 品質の主観性 : 「良い要約」「適切な回答」の判定は、従来の assertEquals では表現できません。人間が読んで「これは正しい」と判断する基準をコードに落とし込む必要があります。
これら3つの課題に対して、私は以下のテストピラミッドを採用しています。
ユニットテスト(70%) : API をモックし、アプリケーションロジックの正しさを高速に検証する
セマンティックテスト(20%) : AI 出力の「意味」をプログラム的に評価する
統合テスト / E2Eテスト(10%) : 実際の API を呼び出し、エンドツーエンドの動作を検証する
この比率が重要です。統合テストを増やしすぎると API コストが膨らみ、CI の実行時間も長くなります。逆にユニットテストだけではプロンプトの品質劣化を検出できません。
Claude API のモック戦略 — 3つのアプローチと実装
ユニットテストの基盤となるのが API モックです。Claude API のモックには3つのアプローチがあり、テスト対象に応じて使い分けます。
アプローチ1: SDK レベルのモック(推奨)
Anthropic SDK のクライアントをモックする方法。最もシンプルで、大半のユニットテストはこれで十分です。
// src/services/summarizer.ts
import Anthropic from "@anthropic-ai/sdk" ;
export class ArticleSummarizer {
private client : Anthropic ;
constructor ( client ?: Anthropic ) {
// DI パターンでテスト時にモックを注入可能にする
this .client = client ?? new Anthropic ();
}
async summarize ( article : string ) : Promise <{
summary : string ;
keyPoints : string [];
readingTime : number ;
}> {
const response = await this .client.messages. create ({
model: "claude-sonnet-4-6" ,
max_tokens: 1024 ,
messages: [
{
role: "user" ,
content: `以下の記事を要約してください。JSON形式で返してください。
{"summary": "200文字以内の要約", "keyPoints": ["要点1", "要点2", "要点3"], "readingTime": 推定読了時間(分)}
記事: ${ article }` ,
},
],
});
const text =
response.content[ 0 ].type === "text" ? response.content[ 0 ].text : "" ;
try {
return JSON . parse (text);
} catch {
throw new Error (
`AI応答のJSON解析に失敗: ${ text . substring ( 0 , 100 ) }...`
);
}
}
}
// src/services/__tests__/summarizer.test.ts
import { describe, it, expect, vi } from "vitest" ;
import { ArticleSummarizer } from "../summarizer" ;
// モッククライアントを作成するファクトリ関数
function createMockClient ( responseText : string ) {
return {
messages: {
create: vi. fn (). mockResolvedValue ({
content: [{ type: "text" , text: responseText }],
usage: { input_tokens: 100 , output_tokens: 50 },
stop_reason: "end_turn" ,
}),
},
} as any ;
}
describe ( "ArticleSummarizer" , () => {
it ( "正常なJSON応答をパースできる" , async () => {
const mockResponse = JSON . stringify ({
summary: "AIテストに関する記事の要約です" ,
keyPoints: [ "ポイント1" , "ポイント2" , "ポイント3" ],
readingTime: 5 ,
});
const client = createMockClient (mockResponse);
const summarizer = new ArticleSummarizer (client);
const result = await summarizer. summarize ( "テスト記事の本文..." );
// 構造の検証(値の完全一致ではなく型と制約を検証)
expect (result). toHaveProperty ( "summary" );
expect (result.summary. length ). toBeLessThanOrEqual ( 200 );
expect (result.keyPoints). toHaveLength ( 3 );
expect (result.readingTime). toBeGreaterThan ( 0 );
// API呼び出しパラメータの検証
expect (client.messages.create). toHaveBeenCalledWith (
expect. objectContaining ({
model: "claude-sonnet-4-6" ,
max_tokens: 1024 ,
})
);
});
it ( "不正なJSON応答時にエラーをスローする" , async () => {
const client = createMockClient ( "これはJSONではありません" );
const summarizer = new ArticleSummarizer (client);
await expect (summarizer. summarize ( "テスト記事" )).rejects. toThrow (
"AI応答のJSON解析に失敗"
);
});
it ( "空の応答を適切にハンドリングする" , async () => {
const client = {
messages: {
create: vi. fn (). mockResolvedValue ({
content: [{ type: "image" , source: {} }], // textブロックがない
usage: { input_tokens: 10 , output_tokens: 0 },
stop_reason: "end_turn" ,
}),
},
} as any ;
const summarizer = new ArticleSummarizer (client);
await expect (summarizer. summarize ( "テスト" )).rejects. toThrow ();
});
});
ここで重要なのは、テストが検証しているのはアプリケーションロジック であって、AI の出力品質ではないという点です。JSON パースの正常系・異常系、エラーハンドリング、API 呼び出しパラメータの正しさ——これらは決定的にテストできます。
アプローチ2: HTTP レベルのインターセプト(MSW)
API のリクエスト / レスポンスの形式まで含めてテストしたい場合、Mock Service Worker(MSW)を使います。
// test/mocks/handlers.ts
import { http, HttpResponse } from "msw" ;
export const claudeHandlers = [
http. post ( "https://api.anthropic.com/v1/messages" , async ({ request }) => {
const body = ( await request. json ()) as any ;
const userMessage = body.messages?.[ 0 ]?.content || "" ;
// プロンプトの内容に応じてレスポンスを分岐
if (userMessage. includes ( "要約" )) {
return HttpResponse. json ({
id: "msg_test_001" ,
type: "message" ,
role: "assistant" ,
content: [
{
type: "text" ,
text: JSON . stringify ({
summary: "テスト要約" ,
keyPoints: [ "要点A" , "要点B" , "要点C" ],
readingTime: 3 ,
}),
},
],
model: body.model,
stop_reason: "end_turn" ,
usage: { input_tokens: 150 , output_tokens: 80 },
});
}
// デフォルトレスポンス
return HttpResponse. json ({
id: "msg_test_default" ,
type: "message" ,
role: "assistant" ,
content: [{ type: "text" , text: "デフォルトの応答です。" }],
model: body.model,
stop_reason: "end_turn" ,
usage: { input_tokens: 50 , output_tokens: 20 },
});
}),
];
MSW を使うメリットは、リクエストヘッダー(x-api-key や anthropic-version)の検証や、ストリーミングレスポンスのシミュレーションが可能な点です。ただし、セットアップのコストが SDK モックより高いため、認証やネットワーク層のテストに絞って使うのが現実的です。
アプローチ3: レスポンスフィクスチャ(スナップショット的運用)
実際の API レスポンスをファイルに保存し、テストで再利用する方法。初回は実 API を叩いてレスポンスを記録し、以降はそのファイルを読み込みます。
// test/fixtures/record.ts — フィクスチャ記録スクリプト
import Anthropic from "@anthropic-ai/sdk" ;
import { writeFileSync, existsSync, mkdirSync } from "fs" ;
import { createHash } from "crypto" ;
const client = new Anthropic ();
export async function recordFixture (
name : string ,
params : Anthropic . MessageCreateParams
) : Promise < void > {
const dir = `test/fixtures/responses` ;
if ( ! existsSync (dir)) mkdirSync (dir, { recursive: true });
const response = await client.messages. create (params);
const fixture = {
params: { ... params, messages: "[REDACTED]" }, // プロンプトは保存しない
response,
recordedAt: new Date (). toISOString (),
modelVersion: response.model,
};
writeFileSync (
`${ dir }/${ name }.json` ,
JSON . stringify (fixture, null , 2 )
);
console. log ( `✅ フィクスチャ記録: ${ name } (${ response . usage . output_tokens } tokens)` );
}
フィクスチャ方式の注意点として、モデルバージョンが変わったらフィクスチャも更新する必要があります 。recordedAt と modelVersion をメタデータに含めておけば、古いフィクスチャの検出が容易になります。
モックが嘘をつくとき — モック忠実度を実測する
ここまでのモックには、ひとつ穴があります。
前節で作ったモックが返しているのは、すべて「きれいな JSON」です。ところがこの記事の冒頭で自分自身が書いたとおり、Claude は JSON を指示しても余分な説明文を添えることがあります。つまり先ほどのユニットテストは、この記事が最大の失敗要因として挙げた形状をひとつも踏んでいません 。
私も個人開発のなかで同じ落とし穴に落ちました。ユニットテストは全部緑、CI も緑、それでも本番で AI応答のJSON解析に失敗 が出続けたのです。モックが理想的すぎたせいで、テストは「起きてほしい世界」だけを検証していました。
ギャップの大きさは推測せずに数えられます。実際に観測される応答形状を7つ用意し、先ほどの summarize() の解析部分にそのまま通してみました(Node.js v22.22 で実行、モデル呼び出しは伴わない純粋な解析テストです)。
応答の形状 旧実装 旧実装が出すエラー
1. 素の JSON のみ ✅ 成功 —
2. 前置きの文が付く(「承知しました。以下が要約です。」) ❌ 失敗 Unexpected token '承'
3. コードフェンス(json 指定)で囲まれる ❌ 失敗 Unexpected token(バッククォート)
4. max_tokens で途中切断される ❌ 失敗 Unterminated string in JSON at position 70
5. 先頭ブロックが tool_use ❌ 失敗 Unexpected end of JSON input
6. content が空配列 ❌ 失敗 Cannot read properties of undefined
7. 先頭ブロックが thinking ❌ 失敗 Unexpected end of JSON input
7形状のうち通ったのは1つだけ、成功率にして 14% でした。そして前節のモックが再現していたのは、まさにその1つだけです。
エラーメッセージの中身にも注目してください。6 と 7 が返す Cannot read properties of undefined や Unexpected end of JSON input は、原因の切り分けにまったく役立ちません。本番のログでこれを見た当時の私は、プロンプトの問題だと思い込んで丸一日を溶かしました。実際の原因は content[0] を無条件で text とみなしていたことでした。
解析側を形状に耐えるようにする
対策は2段構えです。まず stop_reason を見て「そもそも完結した応答なのか」を判定し、次にブロック配列から text を探して 取り出します。content[0] を決め打ちしないのが要点です。
// src/services/parse-response.ts
import type Anthropic from "@anthropic-ai/sdk" ;
/** テキスト中から最初の JSON オブジェクトを、文字列リテラルを考慮して取り出す */
function extractJson ( text : string ) : unknown {
const fence = text. match ( /` {3} (?:json) ? \s * ( [\s\S] *? )` {3} / );
const body = fence ? fence[ 1 ] : text;
const start = body. indexOf ( "{" );
if (start === - 1 ) throw new Error ( "JSONオブジェクトが見つかりません" );
let depth = 0 ;
let inString = false ;
let escaped = false ;
for ( let i = start; i < body. length ; i ++ ) {
const c = body[i];
if (inString) {
if (escaped) escaped = false ;
else if (c === " \\ " ) escaped = true ;
else if (c === '"' ) inString = false ;
continue ;
}
if (c === '"' ) inString = true ;
else if (c === "{" ) depth ++ ;
else if (c === "}" ) {
depth -- ;
if (depth === 0 ) return JSON . parse (body. slice (start, i + 1 ));
}
}
throw new Error ( "JSONが途中で終わっています(max_tokens 切断の可能性)" );
}
export function parseStructured ( response : Anthropic . Message ) : unknown {
// 1. 完結していない応答は、パースを試みる前に落とす
if (response.stop_reason === "max_tokens" ) {
throw new Error ( "max_tokens で切断されました。max_tokens の引き上げか出力の分割が必要です" );
}
// 2. tool_use があれば、パース不要で構造化済みの値が手に入る
const tool = response.content. find (( b ) => b.type === "tool_use" );
if (tool && tool.type === "tool_use" ) return tool.input;
// 3. thinking ブロック等が先頭に来ても拾えるよう、text を探す
const textBlock = response.content. find (( b ) => b.type === "text" );
if ( ! textBlock || textBlock.type !== "text" ) {
throw new Error ( `テキストブロックがありません (stop_reason=${ response . stop_reason })` );
}
return extractJson (textBlock.text);
}
同じ7形状をこの実装に通した結果が次のとおりです。
応答の形状 旧実装 parseStructured
1. 素の JSON ✅ ✅ 値を取得
2. 前置きの文が付く ❌ ✅ 値を取得
3. コードフェンス囲み ❌ ✅ 値を取得
4. max_tokens 切断 ❌ ⚠️ 「max_tokens で切断されました」
5. tool_use 先頭 ❌ ✅ 値を取得
6. content 空配列 ❌ ⚠️ 「テキストブロックがありません (stop_reason=end_turn)」
7. thinking 先頭 ❌ ✅ 値を取得
1/7 が 5/7 になりました。残る2つは失敗のままですが、これは意図した失敗です。4 と 6 はリトライや設定変更でしか解決できない状況なので、握りつぶさずに、原因が書かれたメッセージで落ちるのが正しい振る舞い だと考えています。テストで検証すべきは「成功すること」ではなく「失敗の仕方が調査可能であること」でした。
モックは観測した形状から作る
そのうえで、モックのファクトリ自体を形状のカタログに変えます。
// test/mocks/response-shapes.ts
const FENCE = "`" . repeat ( 3 );
const VALID = {
summary: "TypeScriptの型システムに関する記事の要約です。ジェネリクスや条件型を扱っています。" ,
keyPoints: [ "静的型付けの利点" , "大規模開発での効果" ],
readingTime: 5 ,
};
export const responseShapes = {
cleanJson : () => msg ( "end_turn" , [{ type: "text" , text: JSON . stringify ( VALID ) }]),
prosePrefixed : () =>
msg ( "end_turn" , [
{ type: "text" , text: `承知しました。以下が要約です。 \n\n ${ JSON . stringify ( VALID ) }` },
]),
fenced : () =>
msg ( "end_turn" , [
{ type: "text" , text: `${ FENCE }json \n ${ JSON . stringify ( VALID ) } \n ${ FENCE }` },
]),
truncated : () =>
msg ( "max_tokens" , [{ type: "text" , text: JSON . stringify ( VALID ). slice ( 0 , 70 ) }]),
toolUse : () =>
msg ( "tool_use" , [{ type: "tool_use" , id: "tu_1" , name: "emit_summary" , input: VALID }]),
emptyContent : () => msg ( "end_turn" , []),
thinkingFirst : () =>
msg ( "end_turn" , [
{ type: "thinking" , thinking: "記事の構造を整理します…" , signature: "sig" },
{ type: "text" , text: JSON . stringify ( VALID ) },
]),
};
function msg ( stopReason : string , content : unknown []) {
return {
id: "msg_test" ,
type: "message" ,
role: "assistant" ,
model: "claude-haiku-4-5" ,
stop_reason: stopReason,
usage: { input_tokens: 100 , output_tokens: 50 },
content,
} as any ;
}
// test/services/parse-response.test.ts
import { describe, it, expect } from "vitest" ;
import { parseStructured } from "../../src/services/parse-response" ;
import { responseShapes } from "../mocks/response-shapes" ;
describe ( "parseStructured — 応答形状の網羅" , () => {
it. each ([ "cleanJson" , "prosePrefixed" , "fenced" , "toolUse" , "thinkingFirst" ] as const )(
"%s は値を取り出せる" ,
( shape ) => {
const result = parseStructured (responseShapes[shape]()) as { summary : string };
expect (result.summary). toContain ( "TypeScript" );
}
);
it ( "max_tokens 切断は原因が分かるメッセージで落ちる" , () => {
expect (() => parseStructured (responseShapes. truncated ())). toThrow ( /max_tokens/ );
});
it ( "content が空なら stop_reason 付きで落ちる" , () => {
expect (() => parseStructured (responseShapes. emptyContent ())). toThrow ( /stop_reason=end_turn/ );
});
});
このテーブル駆動テストを足した日から、私自身が運用しているプロジェクトでは JSON 解析起因の本番エラーが出ていません。新しい形状に遭遇したら responseShapes に1行足すだけで、そのケースが恒久的に守られます。
さらに確実にしたいなら、そもそもテキストを parse しない設計に寄せてください。 tools に出力スキーマを渡して tool_choice で強制すれば、値は tool_use.input として構造化された状態で返ってきます。形状 2・3・7 は発生しなくなり、解析の分岐そのものが減ります。テストで守るより、守る対象を減らすほうが確実です。
セマンティックアサーション — AI出力の「意味」をテストする
ユニットテストではアプリケーションロジックを、セマンティックテストでは AI 出力の品質を検証します。ここが Claude API テストの核心です。
「出力の意味が正しいか」をプログラム的に判定する手法を、私はセマンティックアサーションと呼んでいます。
パターン1: 構造バリデーション(Zod スキーマ)
AI 出力が期待する構造を持つかを、Zod で厳密に検証します。
// src/validators/ai-response.ts
import { z } from "zod" ;
export const SummaryResponseSchema = z. object ({
summary: z
. string ()
. min ( 50 , "要約が短すぎます(50文字以上)" )
. max ( 200 , "要約が長すぎます(200文字以下)" ),
keyPoints: z
. array (z. string (). min ( 10 ))
. min ( 2 , "要点は最低2つ必要" )
. max ( 5 , "要点は最大5つまで" ),
readingTime: z
. number ()
. int ()
. min ( 1 , "読了時間は1分以上" )
. max ( 60 , "読了時間が非現実的" ),
});
export type SummaryResponse = z . infer < typeof SummaryResponseSchema>;
// テストでの使用例
it ( "要約レスポンスがスキーマに適合する" , () => {
const response = {
summary: "Claude APIのテスト戦略について解説した記事で、モック戦略とセマンティックアサーションを組み合わせた実践的手法を紹介している" ,
keyPoints: [
"AIの出力は非決定的なのでテスト戦略を変える必要がある" ,
"セマンティックアサーションで意味レベルの検証が可能" ,
],
readingTime: 8 ,
};
const result = SummaryResponseSchema. safeParse (response);
expect (result.success). toBe ( true );
if ( ! result.success) {
console. error ( "バリデーションエラー:" , result.error. format ());
}
});
パターン2: キーワード+否定キーワード検証
出力に含まれるべきキーワードと、含まれてはいけないキーワードを検証します。完全一致は求めないが、重要な概念がカバーされているかは確認できます。
// test/helpers/semantic-assertions.ts
export function assertContainsKeywords (
text : string ,
required : string [],
forbidden : string [] = []
) : void {
const missing = required. filter (
( kw ) => ! text. toLowerCase (). includes (kw. toLowerCase ())
);
if (missing. length > 0 ) {
throw new Error (
`必須キーワードが不足: [${ missing . join ( ", " ) }] \n 出力の先頭200文字: ${ text . substring ( 0 , 200 ) }`
);
}
const found = forbidden. filter (( kw ) =>
text. toLowerCase (). includes (kw. toLowerCase ())
);
if (found. length > 0 ) {
throw new Error (
`禁止キーワードを検出: [${ found . join ( ", " ) }] \n 出力の先頭200文字: ${ text . substring ( 0 , 200 ) }`
);
}
}
// 使用例
assertContainsKeywords (
aiResponse,
[ "Claude" , "API" , "テスト" ], // 必ず含まれるべき
[ "ChatGPT" , "OpenAI" , "GPT-4" ] // 含まれてはいけない
);
パターン3: LLM-as-Judge(AI による出力評価)
最も強力ですが、コストもかかる手法です。Claude 自身に出力の品質を評価させます。
そして、ここには前節の話がそのまま効いてきます。私が最初に書いた judge は、評価結果の JSON を content[0] から JSON.parse していました。7形状のうち1つしか通らないと分かった、あの解析そのものです。評価器が壊れた解析の上に建っていると、テストは品質ではなく解析の失敗を測ってしまいます。
実際に何が起きるかというと、judge が「4点です。理由は…」と前置き付きで返した瞬間にパースが失敗し、スコアが 0 として扱われます。プロンプトの品質は落ちていないのに、リグレッションテストだけが赤くなるのです。原因を追う側からすると、これほど紛らわしい失敗もありません。
対策は、判定結果をツール呼び出しで受け取ることです。tool_choice で採点ツールを強制すれば、スコアと理由は構造化された値として返り、パースの分岐そのものが消えます。
// src/evaluation/llm-judge.ts
import Anthropic from "@anthropic-ai/sdk" ;
export interface EvaluationResult {
score : number ; // 1〜5
reasoning : string ;
passed : boolean ;
}
const JUDGE_TOOL : Anthropic . Tool = {
name: "submit_evaluation" ,
description: "出力を評価基準に照らして採点する" ,
input_schema: {
type: "object" ,
properties: {
score: { type: "integer" , minimum: 1 , maximum: 5 , description: "1が不十分、5が優秀" },
reasoning: { type: "string" , description: "その点数にした根拠を1〜2文で" },
},
required: [ "score" , "reasoning" ],
},
};
export async function evaluateWithLLM (
client : Anthropic ,
input : string ,
output : string ,
criteria : string
) : Promise < EvaluationResult > {
const response = await client.messages. create ({
model: "claude-sonnet-4-6" , // 判定側は本番より上位のモデルを使う
max_tokens: 512 ,
temperature: 0 , // 判定のばらつきを構造的に減らす
tools: [ JUDGE_TOOL ],
tool_choice: { type: "tool" , name: "submit_evaluation" }, // 採点を必ず構造化して受け取る
messages: [
{
role: "user" ,
content: [
"次の入力に対する出力を、評価基準に照らして採点してください。" ,
`# 評価基準 \n ${ criteria }` ,
`# 入力 \n ${ input }` ,
`# 出力 \n ${ output }` ,
]. join ( " \n\n " ),
},
],
});
const tool = response.content. find (( b ) => b.type === "tool_use" );
if ( ! tool || tool.type !== "tool_use" ) {
// スコア0で握りつぶさず、判定不能として落とす
throw new Error ( `判定結果を取得できません (stop_reason=${ response . stop_reason })` );
}
const { score , reasoning } = tool.input as { score : number ; reasoning : string };
return { score, reasoning, passed: score >= 3 };
}
判定に使うモデルを本番より上位にしているのは意図的です。評価は生成より難しい仕事なので、ここを節約すると閾値の意味が薄れます。統合テストで Haiku を使うのとは判断が逆になる場面です。
もうひとつ、throw にした点も意図があります。旧実装は解析に失敗するとスコア0を返していましたが、これは「品質が最低だった」と「採点できなかった」を同じ値に潰す設計でした。測定不能を測定結果として扱わない ——テストの信頼性はここで決まります。
それでも LLM-as-Judge には、評価の一貫性が保証されない という残る課題があります。同じ入力・出力でも評価者の AI が異なるスコアを返すことがあります。temperature: 0 でかなり減りますが、ゼロにはなりません。対策として、同一の評価を3回実行してスコアの中央値を採用する多数決方式が実用的です。
export async function evaluateWithConsensus (
client : Anthropic ,
input : string ,
output : string ,
criteria : string ,
runs : number = 3
) : Promise < EvaluationResult > {
const results = await Promise . all (
Array. from ({ length: runs }, () =>
evaluateWithLLM (client, input, output, criteria)
)
);
const sorted = [ ... results]. sort (( a , b ) => a.score - b.score);
const median = sorted[Math. floor (sorted. length / 2 )];
return {
score: median.score,
reasoning: `${ median . reasoning }(${ runs }回の評価: ${ results . map (( r ) => r . score ). join ( ", " ) })` ,
passed: median.score >= 3 ,
};
}
reasoning に3回分のスコアを併記しているのは、CI のログを後から読むためです。中央値が3でも内訳が 2, 3, 5 なら判定が割れていて、閾値ぎりぎりの題材だと分かります。3, 3, 3 なら安定しています。この差はスコアだけを見ていると永遠に見えません。
統合テストで実際のAPIを叩く — コストとの戦い方
統合テストでは実際の Claude API を呼び出します。ここで最大の課題はコスト管理です。無計画に統合テストを増やすと、月のAPI費用がテストだけで数百ドルに膨らみます。
コスト最適化の4原則
原則1: モデルを使い分ける 。統合テストでは claude-haiku-4-5 を使います。レスポンスの構造や基本的な品質は Haiku でも十分に検証できます。本番で Opus や Sonnet を使う場合でも、テストで Haiku を使うことにリスクはほとんどありません。
原則2: max_tokens を絞る 。テスト用のプロンプトでは、出力の長さを必要最小限に指定します。「3文以内で要約して」のように明示すると、トークン消費を大幅に削減できます。
原則3: テストの実行頻度を制御する 。統合テストは CI/CD パイプラインでの毎回実行ではなく、日次やプロンプト変更時に限定します。
原則4: レスポンスをキャッシュする 。同一プロンプトのテストを複数回実行する場合、初回のレスポンスをキャッシュして再利用します。
// test/helpers/cached-client.ts
import Anthropic from "@anthropic-ai/sdk" ;
import { createHash } from "crypto" ;
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs" ;
const CACHE_DIR = "test/.api-cache" ;
/** キーの並び順に依存しない安定シリアライズ(キャッシュキー用) */
function stableStringify ( value : unknown ) : string {
if (Array. isArray (value)) return `[${ value . map ( stableStringify ). join ( "," ) }]` ;
if (value && typeof value === "object" ) {
const entries = Object. entries (value as Record < string , unknown >)
. sort (([ a ], [ b ]) => a. localeCompare (b))
. map (([ k , v ]) => `${ JSON . stringify ( k ) }:${ stableStringify ( v ) }` );
return `{${ entries . join ( "," ) }}` ;
}
return JSON . stringify (value);
}
export class CachedTestClient {
private client : Anthropic ;
private ttlMs : number ;
constructor ( ttlMs : number = 24 * 60 * 60 * 1000 ) {
this .client = new Anthropic ();
this .ttlMs = ttlMs;
if ( ! existsSync ( CACHE_DIR )) mkdirSync ( CACHE_DIR , { recursive: true });
}
async createMessage (
params : Anthropic . MessageCreateParams
) : Promise < Anthropic . Message > {
const cacheKey = createHash ( "sha256" )
. update ( stableStringify (params))
. digest ( "hex" );
const cachePath = `${ CACHE_DIR }/${ cacheKey }.json` ;
// キャッシュが存在し、TTL内ならキャッシュを返す
if ( existsSync (cachePath)) {
const cached = JSON . parse ( readFileSync (cachePath, "utf-8" ));
if (Date. now () - cached.timestamp < this .ttlMs) {
console. log ( ` 📦 キャッシュヒット: ${ cacheKey . substring ( 0 , 8 ) }...` );
return cached.response;
}
}
// キャッシュミス — 実APIを呼び出して保存
console. log ( ` 🌐 API呼び出し: ${ cacheKey . substring ( 0 , 8 ) }...` );
const response = await this .client.messages. create (params);
writeFileSync (
cachePath,
JSON . stringify ({ timestamp: Date. now (), response, params })
);
return response;
}
}
ここで JSON.stringify(params) をそのままハッシュ化しないのには理由があります。JSON.stringify はキーの挿入順をそのまま反映するため、内容が同一でもキーの並びが違うだけで別のキャッシュキーになります 。
手元で確かめると、{ model, max_tokens, messages } と { max_tokens, messages, model } は sha256 の先頭12桁が a3ceae9441a8 と 5a8bb061b1e9 に分かれました。キーを並べ替えて正規化した場合は両方とも 049b803b0e5c で一致します。
呼び出し側でオブジェクトリテラルの順序が揺れただけでキャッシュが素通りし、意図せず実 API を叩いてしまう——コスト削減のために入れた仕組みが静かに効かなくなる、気づきにくいタイプの不具合です。
統合テストの実装例
// test/integration/summarizer.integration.test.ts
import { describe, it, expect } from "vitest" ;
import { CachedTestClient } from "../helpers/cached-client" ;
import { ArticleSummarizer } from "../../src/services/summarizer" ;
import { SummaryResponseSchema } from "../../src/validators/ai-response" ;
import { assertContainsKeywords } from "../helpers/semantic-assertions" ;
// 統合テストは明示的にフラグで制御
const RUN_INTEGRATION =
process.env. RUN_INTEGRATION_TESTS === "true" ;
describe. skipIf ( ! RUN_INTEGRATION )(
"ArticleSummarizer — 統合テスト" ,
() => {
const cachedClient = new CachedTestClient ();
it ( "実APIで記事を要約し、スキーマに適合する応答を返す" , async () => {
const testArticle = `
TypeScriptの型システムは、JavaScriptに静的型付けを追加する強力な仕組みです。
ジェネリクス、条件型、マップ型などの高度な機能により、
型安全なコードを書きながら柔軟性を維持できます。
特に大規模プロジェクトでは、型チェックによるバグの早期発見が開発効率を大幅に向上させます。
` ;
// Anthropicクライアントのインターフェースに合わせてラップ
const wrappedClient = {
messages: { create : ( p : any ) => cachedClient. createMessage (p) },
} as any ;
const summarizer = new ArticleSummarizer (wrappedClient);
const result = await summarizer. summarize (testArticle);
// 構造バリデーション
const validation = SummaryResponseSchema. safeParse (result);
expect (validation.success). toBe ( true );
// セマンティック検証
assertContainsKeywords (
result.summary,
[ "TypeScript" ],
[ "Python" , "Java" ]
);
// 要点の妥当性
expect (result.keyPoints. length ). toBeGreaterThanOrEqual ( 2 );
expect (result.readingTime). toBeLessThan ( 10 );
}, 30_000 ); // タイムアウトを30秒に設定
}
);
E2Eテストで AI 搭載機能のユーザー体験を検証する
E2E テストでは、ユーザーの操作フローを通じて AI 機能が正しく動作するかを検証します。Playwright を使った実装例を示します。
// e2e/ai-chat.spec.ts
import { test, expect } from "@playwright/test" ;
test. describe ( "AI チャット機能" , () => {
test ( "ユーザーが質問を送信し、AI応答が表示される" , async ({ page }) => {
await page. goto ( "/chat" );
// メッセージ入力
const input = page. getByPlaceholder ( "メッセージを入力..." );
await input. fill ( "TypeScriptのジェネリクスとは何ですか?" );
await page. getByRole ( "button" , { name: "送信" }). click ();
// ローディング表示の確認
await expect (page. getByTestId ( "loading-indicator" )). toBeVisible ();
// AI応答の表示を待機(最大30秒)
const response = page. getByTestId ( "ai-response" ). last ();
await expect (response). toBeVisible ({ timeout: 30_000 });
// 応答内容の基本検証
const text = await response. textContent ();
expect (text). toBeTruthy ();
expect (text ! . length ). toBeGreaterThan ( 50 );
// UIの状態検証
await expect (page. getByTestId ( "loading-indicator" )).not. toBeVisible ();
await expect (input). toBeEmpty (); // 入力欄がクリアされている
});
test ( "APIエラー時にエラーメッセージが表示される" , async ({ page }) => {
// APIをモックしてエラーを返す
await page. route ( "**/api/chat" , ( route ) =>
route. fulfill ({
status: 429 ,
body: JSON . stringify ({ error: "Rate limit exceeded" }),
})
);
await page. goto ( "/chat" );
await page. getByPlaceholder ( "メッセージを入力..." ). fill ( "テスト" );
await page. getByRole ( "button" , { name: "送信" }). click ();
// エラーメッセージの表示確認
await expect (page. getByTestId ( "error-message" )). toContainText (
/しばらく時間をおいて | 再度お試し/
);
// リトライボタンの存在確認
await expect (
page. getByRole ( "button" , { name: /再試行 | リトライ/ })
). toBeVisible ();
});
});
E2E テストでの注意点は、AI 応答の待機時間をユニットテストより大幅に長く設定する こと。Claude API のレスポンスは通常 2〜15 秒かかるため、デフォルトの 5 秒タイムアウトでは安定しません。
CI/CDパイプラインへの組み込み — GitHub Actions 実装例
テスト戦略を CI/CD に組み込む際の鍵は、テストレイヤーごとの実行条件を分離する ことです。
# .github/workflows/ai-tests.yml
name : AI Application Tests
on :
push :
branches : [ main ]
pull_request :
branches : [ main ]
schedule :
- cron : "0 9 * * 1-5" # 平日9:00 UTCに統合テスト実行
jobs :
unit-tests :
runs-on : ubuntu-latest
steps :
- uses : actions/checkout@v4
- uses : actions/setup-node@v4
with :
node-version : "22"
cache : "npm"
- run : npm ci
- run : npx vitest run --reporter=verbose
env :
# ユニットテストではAPIキー不要(モックのみ)
NODE_ENV : test
integration-tests :
# PRへのpushまたはスケジュール実行時のみ
if : github.event_name == 'schedule' || contains(github.event.head_commit.message, '[integration]')
runs-on : ubuntu-latest
needs : unit-tests
steps :
- uses : actions/checkout@v4
- uses : actions/setup-node@v4
with :
node-version : "22"
cache : "npm"
- run : npm ci
- run : npx vitest run --reporter=verbose
env :
RUN_INTEGRATION_TESTS : "true"
ANTHROPIC_API_KEY : ${{ secrets.ANTHROPIC_API_KEY_TEST }}
- name : コスト集計レポート
if : always()
run : |
# キャッシュ統計を出力
CACHE_HITS=$(find test/.api-cache -name "*.json" -mmin -5 | wc -l)
echo "### 統合テスト実行レポート" >> $GITHUB_STEP_SUMMARY
echo "- キャッシュヒット: ${CACHE_HITS} 件" >> $GITHUB_STEP_SUMMARY
コスト安全策として重要なのは、テスト用の API キーを本番と分離すること です。Anthropic のダッシュボードでテスト専用のキーを発行し、月額の使用上限(例: $10)を設定しておきます。これにより、テストのバグで無限ループが発生してもコストが青天井になることはありません。
プロンプト変更のリグレッションテスト — 変更を安全に行うための仕組み
プロダクションで最も怖いのは「プロンプトを少し変えたら出力品質が大幅に下がった」というリグレッションです。これを防ぐために、プロンプト変更時に自動実行されるリグレッションテストを組みます。
// test/regression/prompt-regression.test.ts
import { describe, it, expect } from "vitest" ;
import { CachedTestClient } from "../helpers/cached-client" ;
import { evaluateWithConsensus } from "../../src/evaluation/llm-judge" ;
import Anthropic from "@anthropic-ai/sdk" ;
const TEST_CASES = [
{
name: "技術記事の要約" ,
input: "React Server Componentsは、サーバーサイドでレンダリングされるReactコンポーネントです..." ,
criteria: "技術的に正確で、初心者にもわかりやすい要約になっているか。重要な概念(サーバーサイドレンダリング、パフォーマンス向上)が含まれているか。" ,
minScore: 3 ,
},
{
name: "エラーメッセージの生成" ,
input: "TypeError: Cannot read properties of undefined (reading 'map')" ,
criteria: "エラーの原因を正確に特定し、具体的な修正案を提示しているか。初心者にも実行可能な手順になっているか。" ,
minScore: 3 ,
},
];
const RUN_REGRESSION = process.env. RUN_REGRESSION_TESTS === "true" ;
describe. skipIf ( ! RUN_REGRESSION )( "プロンプトリグレッション" , () => {
const client = new Anthropic ();
TEST_CASES . forEach (({ name , input , criteria , minScore }) => {
it ( `${ name }: スコア ${ minScore } 以上` , async () => {
// 現在のプロンプトでAPIを呼び出す
const response = await client.messages. create ({
model: "claude-haiku-4-5" ,
max_tokens: 512 ,
messages: [{ role: "user" , content: input }],
});
const output =
response.content[ 0 ].type === "text"
? response.content[ 0 ].text
: "" ;
// LLM-as-Judge で評価(3回の多数決)
const evaluation = await evaluateWithConsensus (
client,
input,
output,
criteria
);
console. log (
` 📊 ${ name }: score=${ evaluation . score }, reason=${ evaluation . reasoning }`
);
expect (evaluation.score). toBeGreaterThanOrEqual (minScore);
}, 60_000 );
});
});
よくある間違いと落とし穴
落とし穴1: 文字列の完全一致でテストを書く
最もよくある失敗パターン。expect(response).toBe("特定の文字列") は AI テストでは機能しません。代わりに構造検証(Zod)、キーワード検証、LLM-as-Judge を使います。
// ❌ やってはいけない
expect (summary). toBe ( "TypeScriptは静的型付け言語です。" );
// ✅ 正しいアプローチ
expect (summary. length ). toBeGreaterThan ( 20 );
expect (summary. length ). toBeLessThan ( 200 );
assertContainsKeywords (summary, [ "TypeScript" , "型" ]);
const validation = SummarySchema. safeParse ({ summary });
expect (validation.success). toBe ( true );
落とし穴2: 全テストで実APIを呼び出す
CI が回るたびに数百回の API 呼び出しが発生し、月末にコスト明細を見て愕然とします。テストピラミッドの原則に従い、実 API を使う統合テストは全体の 10% 以下に抑えます。
落とし穴3: ストリーミングレスポンスのテストを忘れる
本番ではストリーミング(stream: true)を使っているのに、テストでは通常レスポンスしか検証していないケース。ストリーミング固有のエラー(途中切断、チャンク結合の失敗)はユニットテストでもモックすべきです。
// ストリーミングのモック例
const mockStream = {
async * [Symbol.asyncIterator]() {
yield { type: "content_block_start" , content_block: { type: "text" , text: "" } };
yield { type: "content_block_delta" , delta: { type: "text_delta" , text: "部分" } };
yield { type: "content_block_delta" , delta: { type: "text_delta" , text: "応答" } };
yield { type: "message_stop" };
},
};
落とし穴4: テスト環境と本番環境でモデルバージョンが乖離する
テストでは claude-haiku-4-5 を使い、本番では claude-sonnet-4-6 を使う場合、モデル固有の挙動差を見逃す可能性があります。特に Structured Output のフォーマット精度はモデルによって異なります。定期的に本番モデルでの統合テストも実行してください。
落とし穴5: テストデータに本番データを使う
個人情報や機密データがテストフィクスチャに混入するリスク。テストデータは必ず合成データを使い、リポジトリにコミットしても問題ないことを確認します。
全体を振り返って — 明日から始める3つのアクション
Claude API アプリケーションのテストは「完璧を目指す」のではなく「段階的に育てる」アプローチが現実的です。明日から始めるなら、以下の3つを順番に実装するのがおすすめです。
応答形状のカタログを作り、SDK モックで回す : 前置き付き・コードフェンス囲み・max_tokens 切断・tool_use・空 content の5つを最初に足してください。実測では、きれいな JSON しか想定しない解析は7形状のうち1つしか通りませんでした。コストゼロで今日から始められます
Zod スキーマで出力バリデーションを追加する : テストだけでなく、本番のランタイムバリデーションとしても機能する
週次の統合テストを CI に組み込む : CachedTestClient でコストを抑えつつ、プロンプトの品質劣化を早期に検出する
テストの品質が上がれば、プロンプトの改善サイクルも加速します。「変更しても壊れない」という安心感が、より大胆な改善を可能にするのです。
本番で実際に遭遇するパース失敗の原因を先に潰しておきたい方は「Claude APIのJSON出力がパースできない——よくある5つの原因と即効コード 」を、テキストを解析しない設計へ寄せる具体策は「Claudeの構造化出力を本番で信頼する — 検証ゲートと修復ループの設計 」をあわせてご覧ください。リトライやエラー分類の設計は「Claude API エラーハンドリングとリトライ戦略 」にまとめています。