Claude Code로 API 테스트 자동화하기: 실전 가이드
Claude Code로 API 테스트를 실전 자동화합니다. 스모크, 인증, JSON 검증, 계약 테스트, CI 예제를 다룹니다.
API 테스트는 브라우저 화면을 열기 전에 서버가 약속한 응답을 지키는지 확인하는 테스트입니다. 로그인 세션이 만들어지는지, 주문이 생성되는지, 실패 시 올바른 오류가 내려오는지, 인증이 빠지면 막히는지, JSON 구조가 클라이언트가 기대한 형태인지 직접 HTTP 요청으로 검증합니다.
Claude Code에 단순히 “API 테스트를 작성해줘”라고 요청하면 결과가 너무 얇아지기 쉽습니다. 성공 요청 하나와 200 OK 확인 하나로 끝나면 실제 품질 보증이 되지 않습니다. 실무에서는 smoke test, 상태 코드, JSON shape, 인증, negative test, contract test, 테스트 데이터, CI까지 이어져야 합니다.
이 글은 초보자도 복사해서 실행할 수 있는 API 테스트 흐름을 설명합니다. API 설계를 함께 정리하려면 API 설계 가이드, 깨지지 않는 변경은 API 버전 관리 전략, 실패 조사 방식은 오류 진단 가이드와 같이 읽으면 좋습니다.
공식 문서는 기준점으로 남겨두는 편이 안전합니다. Playwright를 쓰는 프로젝트라면 Playwright API testing을 보고, 아래 예제의 fetch는 MDN Fetch API를 참고하세요. 계약 테스트의 기준 문서는 OpenAPI Specification입니다.
API 테스트가 증명해야 할 것
API 테스트의 목적은 테스트 개수를 늘리는 것이 아니라 계약이 깨졌는지 빨리 아는 것입니다. E2E 테스트는 전체 사용자 흐름을 검증하므로 중요하지만, 실패 원인이 UI, 인증, 네트워크, 서버, 테스트 데이터, 외부 서비스 중 어디인지 분리하기 어렵습니다. API 테스트는 UI를 건너뛰고 서버 경계를 직접 때리기 때문에 빠르고 원인 분석이 쉽습니다.
| 항목 | 쉬운 설명 | 예시 |
|---|---|---|
| Smoke test | 서비스가 최소한 살아 있는지 확인 | /health는 204, 로그인은 200 |
| 상태 코드 | 결과를 숫자로 표현 | 생성은 201, 인증 없음은 401, 없음은 404 |
| JSON shape | 필수 키와 금지 필드 확인 | sessionId 존재, password 미반환 |
| 인증 | 누가 호출했는지 확인 | Bearer token, Cookie, API key |
| Negative test | 일부러 실패 입력을 보냄 | 틀린 비밀번호, 빈 주문, 서명 없는 webhook |
| Contract test | 공개 약속과 구현이 같은지 확인 | OpenAPI 필수 필드가 응답에 있음 |
| 테스트 데이터 | 매번 같은 조건으로 시작 | 로컬 mock, DB reset, 일회용 주문 ID |
가장 흔한 실수는 200 OK만 보는 것입니다. 200이어도 필드가 사라졌거나, 오류 envelope이 바뀌었거나, 비밀번호가 노출되었거나, 인증 없는 요청이 통과하면 장애입니다. Claude Code에 이런 기대치를 명시해야 테스트가 두꺼워집니다.
실무에서 바로 쓰는 4가지 흐름
아래 예제는 작은 API 테스트 스위트에 꼭 들어갈 만한 4가지 흐름을 하나로 묶습니다.
| 흐름 | 중요한 이유 | 확인할 것 |
|---|---|---|
| 로그인과 세션 smoke test | 대부분의 기능이 세션에 의존 | 200, sessionId, 사용자 구조, 비밀번호 미반환 |
| 주문 생성 API | 매출, 재고, 영수증, 고객지원과 연결 | 201, Location 헤더, 합계, 상세 조회 |
| Webhook 엔드포인트 | 외부 서비스가 비동기로 호출하고 재시도 | 서명 없음 401, 정상 이벤트 202, 중복 이벤트 안전 |
| 버그 회귀 테스트 | 고친 버그가 다시 돌아오지 않게 함 | 400, 401, 404, 안정적인 오류 JSON |
Claude Code에 요청할 때는 이 흐름을 직접 이름으로 적으세요. 특히 webhook은 성공 경로만 테스트하면 위험합니다. 서명 누락, 중복 event ID, 존재하지 않는 주문 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 예제
다음 코드는 운영 서비스나 실제 데이터베이스에 연결하지 않습니다. 작은 로컬 HTTP 서버를 띄우고, Node.js 내장 fetch로 로그인, 주문 생성, webhook, 실패 케이스를 검증합니다. api-smoke.test.mjs로 저장하고 Node.js 18 이상에서 실행하세요.
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가 아니라 안전한 로컬 mock입니다. 실제 프로젝트에서는 makeApp() 대신 로컬 앱 서버, staging URL, 또는 Playwright의 request fixture를 사용할 수 있습니다. 그래도 상태 코드, JSON shape, 인증 경계, negative test, 비밀값 누출 확인은 유지해야 합니다.
Claude Code에 이렇게 요청하기
Claude Code에는 테스트 대상과 실패 조건을 같이 줘야 합니다. 아래 prompt는 성공 경로 하나로 끝나는 일을 막아줍니다.
Add API tests for these flows:
- login and session check
- order creation API
- payment webhook
- regression coverage for the last bug
Must verify:
- success status codes and JSON shape
- missing auth, invalid input, unknown ID, and missing webhook signature
- password, tokens, and secrets are never returned or logged
- test data does not collide across parallel test runs
- a command that CI can run
After the edit, summarize which incidents the tests would catch and which command verifies them.
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]
이 조각만 있어도 생성은 201, 응답은 order로 감싸기, 필수 필드는 id, status, totalCents, items라는 규칙이 생깁니다. 리뷰에서는 구현, OpenAPI, 테스트, README가 동시에 맞는지 확인하세요.
테스트 데이터와 CI
API 테스트의 안정성은 데이터에서 흔들립니다. 같은 demo@example.com, 같은 주문 ID, 같은 webhook event ID를 쓰면 병렬 CI에서 서로 간섭합니다. 작은 서비스는 테스트 실행마다 DB를 초기화하고, 주문이나 webhook은 일회용 ID를 쓰고, 결제나 메일 같은 외부 API는 일상 CI에서는 mock으로 분리하는 방식이 현실적입니다.
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
빠른 API smoke test를 CI 앞쪽에 두면 무거운 브라우저 테스트 전에 인증, JSON 구조, webhook 회귀를 먼저 잡을 수 있습니다. staging 서비스를 호출한다면 Authorization 헤더, session cookie, webhook secret, API key를 로그에 출력하지 않는 규칙도 같이 넣어야 합니다.
자주 터지는 실패 사례
첫째, 200 OK만 확인하는 테스트입니다. 상태 코드가 성공이어도 필수 필드가 빠졌거나 민감 필드가 포함되면 실패입니다.
둘째, 공유 테스트 데이터입니다. 한 사용자와 한 event ID에 의존하면 테스트 순서와 병렬 실행에 약해집니다.
셋째, secret 로그 출력입니다. 디버깅을 위해 token을 그대로 찍으면 CI 로그와 채팅에 남습니다.
넷째, 외부 의존성의 flaky 실패입니다. 결제, 메일, CRM은 일상 CI에서는 mock으로 두고, 릴리스 전 제한된 staging 확인을 추가하는 편이 안전합니다.
다섯째, negative test 부재입니다. 인증 없음, 잘못된 payload, 없는 ID, 권한 부족, webhook 서명 불일치는 예외가 아니라 API 계약의 일부입니다.
여섯째, mock만 믿는 것입니다. mock은 빠르지만 실제 header, timeout, 오류 envelope, 상태 코드 차이를 숨깁니다. 위험한 연동은 계약 테스트나 staging 검증을 남겨두세요.
정리와 상담 CTA
Claude Code로 API 테스트를 만들 때는 먼저 지켜야 할 약속을 정의하세요. 로그인은 살아 있어야 하고, 주문 생성은 안정적이어야 하며, webhook은 검증되어야 하고, 과거 버그는 회귀 테스트로 남아야 합니다. 그다음 Claude Code가 이 약속을 코드와 CI에 반영하게 합니다.
팀에서 Claude Code를 도입한다면 테스트 코드만이 아니라 prompt, OpenAPI 업데이트, 리뷰 기준, CI gate, 실패 대응까지 표준화해야 효과가 납니다. 실제 저장소에 맞춘 지원이 필요하면 Claude Code training and consultation 페이지에서 상담할 수 있습니다. 개인 개발자는 먼저 무료 치트시트와 이 글의 prompt를 적용해 보세요.
Masa가 이 글의 로컬 Node server로 직접 검증해 보니, 가장 큰 효과는 로그인, 주문 생성, webhook 검증, 회귀 테스트를 node api-smoke.test.mjs 한 명령으로 묶은 점이었습니다. 단순 200 OK 확인보다 훨씬 많은 문제를 잡았습니다. password 노출, 인증 누락, 서명 없는 webhook, 잘못된 주문 payload, 중복 webhook 이벤트를 모두 확인할 수 있었습니다.
무료 PDF: Claude Code 치트시트
이메일을 입력하면 명령, 리뷰 습관, 안전한 워크플로를 정리한 PDF를 받을 수 있습니다.
개인정보를 안전하게 관리하며 스팸을 보내지 않습니다.
작성자 소개
Masa
Claude Code 실무 워크플로와 팀 도입을 검증하는 엔지니어입니다.
관련 글
Obsidian 메모를 CLAUDE.md로 바꾸는 Claude Code 워크플로
Obsidian 작업 메모를 CLAUDE.md 운영 노트로 정리해 Claude Code 세션의 문맥 반복을 줄입니다.
Claude Code Revenue CTA Routing: 글에서 PDF, Gumroad, 상담으로 보내기
독자 의도에 따라 무료 PDF, Gumroad 상품, 상담으로 나누는 Claude Code CTA 설계입니다.
Claude Code 팀 인계 규칙: 리뷰 증거, 권한, 롤백, 수익 경로까지 넘기는 법
Claude Code 작업을 팀에 넘길 때 필요한 증거, 권한 규칙, 롤백, 무료 PDF, Gumroad, 상담 경로 체크리스트.