Claude CodeでAPIテストを自動化する実践ガイド
Claude CodeでAPIテストを自動化する実践ガイド。認証、JSON検証、契約テスト、CIまで解説。
APIテストは、画面を開く前に「サーバーが約束どおり返しているか」を確かめるためのテストです。ログインできるか、注文を作れるか、失敗時に正しいエラーを返すか、JSONの形が壊れていないかを、HTTPリクエストで直接確認します。
Claude Codeに「APIテストを書いて」とだけ頼むと、200 OKだけを確認する薄いテストになりがちです。公開前に本当に欲しいのは、ステータスコード、JSONの形、認証、異常系、契約、テストデータ、CIまでつながった確認です。
この記事では、初心者がそのまま使えるAPIテストの型を、Claude Codeに渡す指示、コピペで動くNode.jsコード、CIへの入れ方までまとめます。APIの設計から見直すならAPI設計ガイド、壊さず変更するならAPIバージョニング戦略、失敗調査の型はエラー診断ガイドも合わせて確認してください。
公式情報として、Playwrightを使う場合はPlaywright API testing、ブラウザやNode.jsのfetchの基本はMDN Fetch API、契約テストの土台はOpenAPI Specificationを参照します。
APIテストで何を見るか
APIテストの最初の目的は「壊れているかどうかを早く知ること」です。E2Eテストは画面、ブラウザ、認証、ネットワーク、バックエンドが全部動くので価値がありますが、失敗時に原因の切り分けが遅くなります。APIテストは画面を飛ばして、サーバーの入出力だけを短時間で検証できます。
基本の観点は次の7つです。
| 観点 | 初心者向けの言い換え | 例 |
|---|---|---|
| スモークテスト | 最低限の生存確認 | /healthが204、ログインが200 |
| ステータスコード | 結果を数字で示す合図 | 作成は201、認証なしは401、未存在は404 |
| JSON shape | JSONの形と必須キー | sessionIdが文字列、passwordは返さない |
| 認証 | 誰として呼んでいるか | Bearer token、Cookie、APIキー |
| negative test | 失敗する入力をあえて送る確認 | パスワード違い、空の注文、署名なしWebhook |
| contract test | APIの約束と実装のズレ確認 | OpenAPIの必須フィールドとレスポンスを比べる |
| テストデータ | 毎回同じ条件で始める仕組み | ローカルDB、モック、使い捨て注文ID |
ここで重要なのは、200 OKだけを見ないことです。200が返っても、金額フィールドが消えていたり、エラー形式が画面側の想定と違っていたり、認証なしでも注文が作れてしまったりすれば、品質保証としては失敗です。
4つの実用ユースケース
この記事のサンプルでは、現場でよく使う4つのユースケースを1本のローカルテストにまとめます。
| ユースケース | テストする理由 | 見るポイント |
|---|---|---|
| ログインとセッションのスモークテスト | ほぼ全機能の入口だから | 200、sessionId、ユーザー情報、パスワード非返却 |
| 注文作成API | 売上や在庫に直結しやすいから | 201、Locationヘッダー、合計金額、詳細取得 |
| Webhookエンドポイント | 外部サービスから非同期で呼ばれるから | 署名なし401、正常202、重複イベントの安全処理 |
| バグ再発防止テスト | 修正後に同じ事故を戻さないため | 400、401、404、エラーJSONの形 |
Claude Codeに依頼するときは、「この4つを全部入れて」と明示してください。特にWebhookは、成功パスだけを書くと危険です。署名なし、重複イベント、存在しない注文IDを入れないと、本番の事故に近い条件を逃します。
flowchart LR
A["OpenAPI or API notes"] --> B["Claude Code prompt"]
B --> C["Local API test"]
C --> D["Negative tests"]
D --> E["CI gate"]
E --> F["Regression safety"]
コピペで動くNode fetch版APIテスト
次のコードは、外部サービスにも本番DBにも接続しません。Node.js 18以上ならfetchが標準で使えるので、1ファイルだけでローカルHTTPサーバーを立て、ログイン、注文作成、Webhook、異常系まで検証できます。
ファイル名はapi-smoke.test.mjsにしてください。
import assert from "node:assert/strict";
import { randomUUID } from "node:crypto";
import { createServer } from "node:http";
const TEST_USER = {
id: "user_1",
email: "demo@example.com",
password: "correct-horse",
};
const WEBHOOK_SECRET = "whsec_test";
function sendJson(res, status, body, headers = {}) {
if (status === 204) {
res.writeHead(status, headers);
res.end();
return;
}
res.writeHead(status, {
"content-type": "application/json; charset=utf-8",
...headers,
});
res.end(JSON.stringify(body));
}
function readJson(req) {
return new Promise((resolve, reject) => {
let raw = "";
req.on("data", (chunk) => {
raw += chunk;
if (raw.length > 1_000_000) req.destroy();
});
req.on("end", () => {
if (!raw) {
resolve({});
return;
}
try {
resolve(JSON.parse(raw));
} catch (error) {
reject(error);
}
});
req.on("error", reject);
});
}
function bearerToken(req) {
const value = req.headers.authorization;
if (typeof value === "string" && value.startsWith("Bearer ")) {
return value.slice("Bearer ".length);
}
return "";
}
function validateItems(items) {
if (!Array.isArray(items) || items.length === 0) {
return ["items must be a non-empty array"];
}
return items.flatMap((item, index) => {
const errors = [];
if (typeof item.sku !== "string" || item.sku.length === 0) {
errors.push(`items[${index}].sku must be a non-empty string`);
}
if (!Number.isInteger(item.quantity) || item.quantity <= 0) {
errors.push(`items[${index}].quantity must be a positive integer`);
}
if (!Number.isInteger(item.priceCents) || item.priceCents < 0) {
errors.push(`items[${index}].priceCents must be a non-negative integer`);
}
return errors;
});
}
function makeApp() {
const sessions = new Map();
const orders = new Map();
const webhookEvents = new Set();
let orderSeq = 1;
return async function handler(req, res) {
const method = req.method ?? "GET";
const url = new URL(req.url ?? "/", "http://localhost");
let body = {};
if (["POST", "PUT", "PATCH"].includes(method)) {
try {
body = await readJson(req);
} catch {
return sendJson(res, 400, {
error: { code: "invalid_json", message: "Request body is not valid JSON" },
});
}
}
const currentUser = () => {
const token = bearerToken(req);
return token ? sessions.get(token) : undefined;
};
if (method === "GET" && url.pathname === "/health") {
return sendJson(res, 204, null);
}
if (method === "POST" && url.pathname === "/login") {
if (body.email !== TEST_USER.email || body.password !== TEST_USER.password) {
return sendJson(res, 401, {
error: { code: "invalid_credentials", message: "Email or password is wrong" },
});
}
const sessionId = `sess_${randomUUID()}`;
sessions.set(sessionId, { id: TEST_USER.id, email: TEST_USER.email });
return sendJson(res, 200, {
sessionId,
expiresIn: 3600,
user: { id: TEST_USER.id, email: TEST_USER.email },
});
}
if (method === "GET" && url.pathname === "/me") {
const user = currentUser();
if (!user) {
return sendJson(res, 401, {
error: { code: "unauthorized", message: "Bearer token is required" },
});
}
return sendJson(res, 200, { user });
}
if (method === "POST" && url.pathname === "/orders") {
const user = currentUser();
if (!user) {
return sendJson(res, 401, {
error: { code: "unauthorized", message: "Bearer token is required" },
});
}
const details = validateItems(body.items);
if (details.length > 0) {
return sendJson(res, 400, {
error: { code: "validation_failed", message: "Order payload is invalid", details },
});
}
const totalCents = body.items.reduce(
(sum, item) => sum + item.quantity * item.priceCents,
0,
);
const order = {
id: `ord_${orderSeq++}`,
userId: user.id,
status: "created",
totalCents,
items: body.items,
};
orders.set(order.id, order);
return sendJson(res, 201, { order }, { location: `/orders/${order.id}` });
}
const orderMatch = url.pathname.match(/^\/orders\/([^/]+)$/);
if (method === "GET" && orderMatch) {
const user = currentUser();
if (!user) {
return sendJson(res, 401, {
error: { code: "unauthorized", message: "Bearer token is required" },
});
}
const order = orders.get(orderMatch[1]);
if (!order || order.userId !== user.id) {
return sendJson(res, 404, {
error: { code: "order_not_found", message: "Order was not found" },
});
}
return sendJson(res, 200, { order });
}
if (method === "POST" && url.pathname === "/webhooks/payment") {
if (req.headers["x-webhook-secret"] !== WEBHOOK_SECRET) {
return sendJson(res, 401, {
error: { code: "bad_signature", message: "Webhook signature is invalid" },
});
}
if (typeof body.eventId !== "string" || typeof body.orderId !== "string") {
return sendJson(res, 400, {
error: { code: "validation_failed", message: "eventId and orderId are required" },
});
}
if (webhookEvents.has(body.eventId)) {
return sendJson(res, 200, { received: true, duplicate: true });
}
const order = orders.get(body.orderId);
if (!order) {
return sendJson(res, 404, {
error: { code: "order_not_found", message: "Order was not found" },
});
}
webhookEvents.add(body.eventId);
order.status = "paid";
return sendJson(res, 202, { received: true, duplicate: false });
}
return sendJson(res, 404, {
error: { code: "route_not_found", message: `${method} ${url.pathname} is not supported` },
});
};
}
async function withServer(fn) {
const server = createServer(makeApp());
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address();
const baseUrl = `http://127.0.0.1:${address.port}`;
try {
await fn(baseUrl);
} finally {
await new Promise((resolve) => server.close(resolve));
}
}
async function requestJson(baseUrl, path, options = {}) {
const headers = { ...(options.headers ?? {}) };
if (options.token) headers.authorization = `Bearer ${options.token}`;
const init = {
method: options.method ?? "GET",
headers,
};
if (options.body !== undefined) {
headers["content-type"] = "application/json";
init.body = JSON.stringify(options.body);
}
const res = await fetch(`${baseUrl}${path}`, init);
const text = await res.text();
return { res, json: text ? JSON.parse(text) : null };
}
function expectKeys(value, keys) {
for (const key of keys) {
assert.ok(Object.prototype.hasOwnProperty.call(value, key), `missing key: ${key}`);
}
}
async function login(baseUrl) {
const { res, json } = await requestJson(baseUrl, "/login", {
method: "POST",
body: { email: TEST_USER.email, password: TEST_USER.password },
});
assert.equal(res.status, 200);
assert.equal(typeof json.sessionId, "string");
return json.sessionId;
}
async function createOrder(baseUrl, token) {
const { res, json } = await requestJson(baseUrl, "/orders", {
method: "POST",
token,
body: {
items: [
{ sku: "book", quantity: 2, priceCents: 1500 },
{ sku: "video", quantity: 1, priceCents: 4000 },
],
},
});
assert.equal(res.status, 201);
assert.match(res.headers.get("location"), /^\/orders\/ord_/);
expectKeys(json.order, ["id", "status", "totalCents", "items"]);
assert.equal(json.order.totalCents, 7000);
return json.order;
}
const tests = [];
function test(name, fn) {
tests.push({ name, fn });
}
test("login/session smoke test", async (baseUrl) => {
const health = await fetch(`${baseUrl}/health`);
assert.equal(health.status, 204);
const { res, json } = await requestJson(baseUrl, "/login", {
method: "POST",
body: { email: TEST_USER.email, password: TEST_USER.password },
});
assert.equal(res.status, 200);
expectKeys(json, ["sessionId", "expiresIn", "user"]);
assert.equal(json.user.email, TEST_USER.email);
assert.equal(json.user.password, undefined);
const me = await requestJson(baseUrl, "/me", { token: json.sessionId });
assert.equal(me.res.status, 200);
assert.equal(me.json.user.id, TEST_USER.id);
});
test("order creation API returns a stable JSON shape", async (baseUrl) => {
const token = await login(baseUrl);
const order = await createOrder(baseUrl, token);
const detail = await requestJson(baseUrl, `/orders/${order.id}`, { token });
assert.equal(detail.res.status, 200);
assert.equal(detail.json.order.id, order.id);
assert.equal(detail.json.order.status, "created");
});
test("payment webhook verifies signature and duplicate events", async (baseUrl) => {
const token = await login(baseUrl);
const order = await createOrder(baseUrl, token);
const noSignature = await requestJson(baseUrl, "/webhooks/payment", {
method: "POST",
body: { eventId: "evt_1", orderId: order.id },
});
assert.equal(noSignature.res.status, 401);
assert.equal(noSignature.json.error.code, "bad_signature");
const accepted = await requestJson(baseUrl, "/webhooks/payment", {
method: "POST",
headers: { "x-webhook-secret": WEBHOOK_SECRET },
body: { eventId: "evt_1", orderId: order.id },
});
assert.equal(accepted.res.status, 202);
assert.equal(accepted.json.duplicate, false);
const paidOrder = await requestJson(baseUrl, `/orders/${order.id}`, { token });
assert.equal(paidOrder.json.order.status, "paid");
const duplicate = await requestJson(baseUrl, "/webhooks/payment", {
method: "POST",
headers: { "x-webhook-secret": WEBHOOK_SECRET },
body: { eventId: "evt_1", orderId: order.id },
});
assert.equal(duplicate.res.status, 200);
assert.equal(duplicate.json.duplicate, true);
});
test("regression tests cover auth, validation, and not-found bugs", async (baseUrl) => {
const badLogin = await requestJson(baseUrl, "/login", {
method: "POST",
body: { email: TEST_USER.email, password: "wrong" },
});
assert.equal(badLogin.res.status, 401);
assert.equal(badLogin.json.error.code, "invalid_credentials");
const missingAuth = await requestJson(baseUrl, "/orders", {
method: "POST",
body: { items: [{ sku: "book", quantity: 1, priceCents: 1500 }] },
});
assert.equal(missingAuth.res.status, 401);
const token = await login(baseUrl);
const invalidOrder = await requestJson(baseUrl, "/orders", {
method: "POST",
token,
body: { items: [{ sku: "book", quantity: 0, priceCents: 1500 }] },
});
assert.equal(invalidOrder.res.status, 400);
assert.equal(invalidOrder.json.error.code, "validation_failed");
assert.ok(Array.isArray(invalidOrder.json.error.details));
const missingOrder = await requestJson(baseUrl, "/orders/ord_missing", { token });
assert.equal(missingOrder.res.status, 404);
assert.equal(missingOrder.json.error.code, "order_not_found");
});
await withServer(async (baseUrl) => {
let failed = 0;
for (const { name, fn } of tests) {
try {
await fn(baseUrl);
console.log(`ok - ${name}`);
} catch (error) {
failed += 1;
console.error(`not ok - ${name}`);
console.error(error);
}
}
if (failed > 0) {
process.exitCode = 1;
}
});
実行コマンドはこれだけです。
node api-smoke.test.mjs
成功すると、4本のテストがokとして表示されます。このサンプルは「本番APIを叩くテスト」ではなく、テストの型を理解するための安全なモックです。実プロジェクトに移すときは、makeApp()の代わりにローカルで起動したアプリ、ステージング環境、またはPlaywrightのrequestフィクスチャを使います。
Claude Codeへの依頼文
Claude Codeに依頼するときは、テスト対象と失敗条件を先に固定します。次のようなプロンプトにすると、200 OKだけのテストで終わりにくくなります。
APIテストを追加してください。
対象:
- ログインとセッション確認
- 注文作成API
- 支払いWebhook
- 過去バグの再発防止
必ず確認すること:
- 正常系のステータスコードとJSON shape
- 認証なし、入力不正、存在しないID、Webhook署名なし
- レスポンスにパスワードやシークレットを含めないこと
- テストデータが他のテストと衝突しないこと
- CIで実行できるコマンド
作業後に、追加したテスト、失敗時に検知できる事故、実行コマンドを短くまとめてください。
さらに既存APIがOpenAPIで管理されているなら、「OpenAPIを契約として扱う」と書きます。contract testは難しい言葉に見えますが、要するに「ドキュメント上の約束と実装結果が同じか」を確認するテストです。
openapi: 3.1.0
info:
title: Local Orders API
version: 1.0.0
paths:
/orders:
post:
responses:
"201":
description: Order created
content:
application/json:
schema:
type: object
required: [order]
properties:
order:
type: object
required: [id, status, totalCents, items]
この断片をClaude Codeに渡すだけでも、「order.idが必要」「作成は201」「レスポンスはorderで包む」という前提を共有できます。API変更が多いチームでは、実装、OpenAPI、テスト、READMEの4つが同時に変わっているかをレビューしてください。
テストデータとCIの入れ方
APIテストの安定性は、テストデータでほぼ決まります。共有のdemo@example.comを全員で使うと、並列実行でセッションが消えたり、注文番号が衝突したりします。ローカルモックならMapで十分ですが、実アプリでは次のどれかを選びます。
| 方法 | 向いている場面 | 注意点 |
|---|---|---|
| テストごとにDBをリセット | 小さなサービス、CI | 本番DBに絶対向けない |
| 使い捨てIDを発行 | 注文、Webhook、外部連携 | 後片付けの仕組みが必要 |
| 読み取り専用fixture | カタログ、公開設定 | 更新系のテストには弱い |
| 外部APIをモック | 決済、メール、CRM | モックだけを信じると本番差分を逃す |
CIでは、速いスモークテストを先に実行します。重いE2Eを回す前にAPIの入口で落とせるからです。
name: api-tests
on:
pull_request:
push:
branches: [main]
jobs:
api:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- run: node api-smoke.test.mjs
本番に近い環境を叩く場合は、ログにシークレットを出さないこともCI要件に入れてください。トークン、Webhook署名、Cookieをconsole.logに出すテストは、通っていても危険です。
よくある失敗例
1つ目は、200 OKだけを見てしまうことです。正常ステータスでもJSONの必須キーが消えていれば、フロントエンドやモバイルアプリは壊れます。最低でもステータス、Content-Type、必須キー、返してはいけないフィールドを見ます。
2つ目は、共有テストデータです。全員が同じユーザー、同じ注文ID、同じWebhookイベントIDを使うと、並列CIで落ちます。ランダムIDやテストごとの初期化を使って、順番に依存しないテストにします。
3つ目は、シークレットをログに出すことです。失敗時のデバッグ用にAuthorizationヘッダーを丸ごと出すと、CIログやチャットに漏れます。Claude Codeにも「ログではトークンをマスクする」と明記してください。
4つ目は、外部依存が不安定なままです。決済、メール、CRMを毎回本物で叩くと、相手の障害やレート制限でテストが落ちます。普段はモック、リリース前は限定的なステージング確認、という層に分けます。
5つ目は、negative testがないことです。入力不正、認証なし、権限不足、未存在ID、Webhook署名不一致を入れないAPIテストは、攻撃や操作ミスに弱いままです。
6つ目は、モックだけを信じることです。モックは速く安定しますが、本番のヘッダー、タイムアウト、エラー形式、ステータスコードとの差分を隠します。契約テストやステージングで、最低限の実通信も残してください。
まとめとCTA
Claude CodeでAPIテストを作るときは、最初に「何を守りたいか」を言語化します。ログインの入口、注文の作成、Webhookの信頼性、過去バグの再発防止を並べ、ステータスコード、JSON shape、認証、異常系、契約、テストデータ、CIまで1本の流れにします。
チームでClaude Codeを導入する場合、テストコードだけでなく、プロンプト、OpenAPI更新、レビュー観点、CIの失敗時対応まで標準化すると効果が出ます。ClaudeCodeLabでは、実リポジトリに合わせたClaude Code研修・相談で、APIテスト設計、契約テスト、CIゲート、レビュー手順の整備を支援しています。個人で始める場合は、まず無料チートシートとこの記事のプロンプトを手元のAPIに合わせてください。
この記事で紹介した内容をMasaが手元で試した結果、最も効果が大きかったのは「ログイン、注文作成、Webhook、過去バグ」を1本の短いAPIテストにまとめることでした。Node.jsのローカルサーバーで動かすと外部依存の揺れがなく、node api-smoke.test.mjsだけでステータスコード、JSON shape、認証漏れ、署名なしWebhook、入力不正を確認できました。逆に、200 OKだけの確認では、passwordを返してしまう事故や重複Webhookの扱いを見逃すことも確認できました。
無料PDF: Claude Code はじめてのチートシート
まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。
スパムは送りません。登録情報は厳重に管理します。
Claude Codeを仕事で使える形にしませんか?
無料PDFで基礎を固めたあと、すぐ使えるテンプレート集で試し、必要なら業務自動化や導入相談まで進められます。
この記事を書いた人
Masa
Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。
関連書籍・参考図書
この記事のテーマに関連する書籍を楽天ブックスで探せます。
※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。
関連記事
ObsidianメモをCLAUDE.mdに変えるClaude Code運用: 文脈を毎回説明しない仕組み
Obsidianの作業メモからCLAUDE.md用の運用ノートを作り、Claude Codeに安定した文脈を渡す方法。
Claude Code Revenue CTA Routing: 記事からPDF、Gumroad、相談へ送る設計
PVだけで終わらせず、読者の状態に合わせて無料PDF、Gumroad教材、導入相談へ分岐するCTA設計です。
Claude Codeチーム引き継ぎルール: レビュー、権限、収益導線まで渡す実務手順
Claude Codeの作業をチームで渡すための証拠、権限、ロールバック、無料PDF/Gumroad/相談導線の実務ルール。