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 をメタデータに含めておけば、古いフィクスチャの検出が容易になります。
セマンティックアサーション — 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 自身に出力の品質を評価させる。
// src/evaluation/llm-judge.ts
import Anthropic from "@anthropic-ai/sdk" ;
interface EvaluationResult {
score : number ; // 1-5
reasoning : string ;
passed : boolean ;
}
export async function evaluateWithLLM (
client : Anthropic ,
input : string ,
output : string ,
criteria : string
) : Promise < EvaluationResult > {
const response = await client.messages. create ({
model: "claude-haiku-4-5" , // 評価にはHaikuで十分(コスト削減)
max_tokens: 512 ,
messages: [
{
role: "user" ,
content: `あなたはAI出力の品質評価者です。以下の基準に基づいて、入力に対する出力を1〜5のスコアで評価してください。
評価基準: ${ criteria }
入力: ${ input }
出力: ${ output }
以下のJSON形式で回答してください:
{"score": 数値(1-5), "reasoning": "評価理由(100文字以内)", "passed": スコアが3以上ならtrue}` ,
},
],
});
const text =
response.content[ 0 ].type === "text" ? response.content[ 0 ].text : "" ;
try {
const result = JSON . parse (text);
return {
score: Number (result.score),
reasoning: String (result.reasoning),
passed: result.score >= 3 ,
};
} catch {
// JSON解析失敗時はテキストからスコアを抽出する
const scoreMatch = text. match ( /"score" \s * : \s * ( \d )/ );
return {
score: scoreMatch ? Number (scoreMatch[ 1 ]) : 0 ,
reasoning: "JSON解析失敗。生テキスト: " + text. substring ( 0 , 100 ),
passed: false ,
};
}
}
LLM-as-Judge は強力だが、評価の一貫性が保証されない という根本的な課題があります。同じ入力・出力でも評価者の AI が異なるスコアを返すことがあります。対策として、同一の評価を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 scores = results. map (( r ) => r.score). sort (( a , b ) => a - b);
const medianScore = scores[Math. floor (scores. length / 2 )];
return {
score: medianScore,
reasoning: results. find (( r ) => r.score === medianScore)?.reasoning || "" ,
passed: medianScore >= 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" ;
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 ( JSON . stringify (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;
}
}
統合テストの実装例
// 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 モックでユニットテストを書く : アプリケーションロジック(JSON パース、エラーハンドリング、リトライ)を決定的にテストします。コストゼロで今すぐ始められる
Zod スキーマで出力バリデーションを追加する : テストだけでなく、本番のランタイムバリデーションとしても機能する
週次の統合テストを CI に組み込む : CachedTestClient でコストを抑えつつ、プロンプトの品質劣化を早期に検出する
テストの品質が上がれば、プロンプトの改善サイクルも加速します。「変更しても壊れない」という安心感が、より大胆な改善を可能にするのです。
より実践的なエラーハンドリングのパターンについては「Claude API エラー完全ガイド」を、Structured Output の活用法については「Claude API Structured Output 実践マスター 」を参照してほしい。