用 Claude Code 自动化 API 测试:实战指南
用 Claude Code 实践 API 测试:冒烟、认证、JSON 结构、契约测试、CI 与可运行示例。
API 测试是在没有打开浏览器页面之前,先确认服务端是否守住公开承诺。它会直接发送 HTTP 请求,检查登录是否可用、订单是否能创建、失败时是否返回清晰错误、认证是否生效,以及 JSON 结构是否仍然符合客户端预期。
如果只让 Claude Code “帮我写 API 测试”,结果很容易变成一个很薄的 happy path:请求成功、断言 200 OK、结束。真正能保护线上系统的测试,需要同时覆盖 smoke test、状态码、JSON shape、认证、负向测试、契约测试、测试数据和 CI。
本文给出一套初学者也能复制使用的流程。API 设计可以继续阅读API 设计指南,不破坏旧客户端的变更可以看API 版本策略,失败排查可以搭配错误诊断流程。
官方资料建议放在任务说明里:使用 Playwright 时参考 Playwright API testing,下面的 Node 示例基于 MDN Fetch API,契约测试的基础可以参考 OpenAPI Specification。
API 测试到底要证明什么
API 测试的第一目标不是“测试数量很多”,而是快速发现契约是否被破坏。端到端浏览器测试当然重要,因为它验证完整用户路径;但失败时,原因可能是 UI、认证、网络、后端、测试数据或外部服务。API 测试跳过 UI,直接验证服务端边界,所以更快、更容易定位问题。
| 检查点 | 通俗解释 | 示例 |
|---|---|---|
| Smoke test | 最小生存检查 | /health 返回 204,登录返回 200 |
| 状态码 | 用数字表达结果 | 创建返回 201,未认证返回 401,找不到返回 404 |
| JSON shape | JSON 的必填键和禁止字段 | 有 sessionId,不返回 password |
| 认证 | 确认调用者身份 | Bearer token、Cookie、API key |
| 负向测试 | 故意发送错误输入 | 密码错误、空订单、没有签名的 webhook |
| 契约测试 | 实现与公开约定一致 | OpenAPI 里的必填字段仍然存在 |
| 测试数据 | 每次运行条件可预测 | 本地 mock、重置数据库、一次性订单 ID |
常见误区是只看 200 OK。响应可以是 200,但仍然因为字段丢失、错误格式变化、敏感信息泄露、未认证请求被接受而导致事故。把这些要求写进 Claude Code 的 prompt,测试质量会明显不同。
4 个实用场景
下面的示例把 4 个真实项目中常见的 API 流程放进同一个本地测试。
| 场景 | 为什么重要 | 需要断言 |
|---|---|---|
| 登录和 session 冒烟测试 | 大多数功能都依赖有效 session | 200、sessionId、用户结构、不返回密码 |
| 创建订单 API | 影响收入、库存、收据和客服 | 201、Location 头、总金额、详情查询 |
| Webhook 端点 | 外部服务异步调用并可能重试 | 无签名 401、正常事件 202、重复事件安全 |
| Bug 回归测试 | 已修复的问题不能悄悄回来 | 400、401、404、稳定的错误 JSON |
向 Claude Code 提需求时,直接写出这些场景。尤其是 webhook,只测成功路径价值很低;必须加入缺少签名、重复事件 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 server,然后用 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。这个示例是安全的本地 mock,不是生产压测。迁移到真实项目时,可以把 makeApp() 换成你本地启动的应用、staging URL,或 Playwright 的 request fixture;断言仍然要保留状态码、JSON shape、认证边界、负向场景和敏感字段检查。
给 Claude Code 的 Prompt
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 文件,要明确告诉 Claude Code:“把 OpenAPI 当作契约”。契约测试并不神秘,就是确认公开文档中的约定与实际响应一致。
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 很容易互相影响。小项目可以每次重置数据库;订单和 webhook 可以使用一次性 ID;外部支付、邮件、CRM 则适合在日常 CI 中 mock,在发布前再做有限的 staging 检查。
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
常见失败有 6 个。第一,只断言 200 OK,忽略必填字段和禁止字段。第二,共享测试数据导致顺序依赖。第三,把 token、Cookie、webhook secret 打进 CI 日志。第四,日常 CI 直接依赖不稳定的外部服务。第五,没有负向测试,认证缺失和输入错误完全没覆盖。第六,只相信 mock,结果错过真实服务的 header、timeout、错误格式和状态码差异。
因此,CI 里先跑快速 API smoke test,再跑更慢的浏览器测试。这样认证、JSON 结构、webhook 回归错误可以更早失败,排查成本也低。
总结与咨询入口
用 Claude Code 写 API 测试时,先定义要守住的承诺:登录可用、订单创建稳定、webhook 经过验证、旧 bug 有回归测试。然后让 Claude Code 实现检查,并把命令放进 CI。
团队导入 Claude Code 时,更大的价值是标准化:prompt、OpenAPI 更新、代码审查清单、CI gate 和失败排查流程。ClaudeCodeLab 可以通过Claude Code 培训与咨询帮助团队把这些规则落到真实仓库。个人开发者可以先使用免费速查表和本文的 prompt。
Masa 按照本文流程在本地 Node server 上实际验证后,最明显的收获是:登录、订单创建、webhook 验证和回归测试可以压缩成一个短命令 node api-smoke.test.mjs。它比只检查 200 OK 更可靠,能够抓到 password 泄露、认证缺失、未签名 webhook、非法订单 payload 和重复 webhook 事件。
免费 PDF: Claude Code 速查表
输入邮箱即可获取一页 PDF,整理常用命令、审查习惯和安全工作流。
我们会妥善保护你的信息,不发送垃圾邮件。
把 Claude Code 变成真正能带来结果的工作流
先领取中文说明的免费 PDF,再进入英文商品页选择合适的教材。如果你需要团队落地、流程设计或内容变现支持,也可以直接咨询。
关于作者
Masa
专注 Claude Code 实务流程、团队导入和内容转化的工程师。
相关文章
从Obsidian到CLAUDE.md的Claude Code流程:不再反复解释上下文
把 Obsidian 工作笔记整理成 CLAUDE.md 运行说明,让 Claude Code 每次都带着正确上下文开始。
Claude Code 收入 CTA 路由:从文章分流到 PDF、Gumroad 与咨询
用 Claude Code 按读者意图把文章流量分到免费 PDF、Gumroad 教材或咨询入口。
Claude Code 团队交接规则: 把审查证据、权限、回滚和收入路径一起交付
面向团队的 Claude Code 交接格式: 证据、权限、回滚、免费 PDF、Gumroad 与咨询路径都要可审查。