API Testing with Claude Code: Practical Automation Guide
Practical API testing with Claude Code: smoke tests, auth, JSON shape, contract tests, CI, and safe examples.
API testing checks whether a server keeps its promises before a browser screen is involved. A good API test verifies that login works, orders can be created, failures return useful errors, authentication is enforced, and the JSON response shape still matches what clients expect.
If you ask Claude Code only to “write API tests”, the first result is often too thin: one happy path, one 200 OK assertion, and no proof that the response is safe for a frontend, mobile app, webhook consumer, or partner integration. The useful target is broader: smoke tests, status codes, JSON shape, authentication, negative tests, contract tests, test data, and CI.
This guide shows a beginner-friendly workflow that you can copy into a real project. For adjacent reading, pair it with the API design guide, API versioning strategy, and error diagnosis workflow.
Official references matter here because API tooling changes quickly. Use the Playwright API testing docs when your project already runs Playwright, the MDN Fetch API reference for the built-in request API used below, and the OpenAPI Specification when you want API contracts to be reviewable instead of living only in code.
What API Tests Should Prove
The first job of an API test is to tell you quickly whether a contract is broken. End-to-end browser tests are still valuable because they cover the full user journey, but they are slower and harder to diagnose when something fails. API tests skip the UI and exercise the server boundary directly.
The core checklist is small but strict.
| Check | Plain-English meaning | Example |
|---|---|---|
| Smoke test | The service is alive and a critical path works | /health returns 204, login returns 200 |
| Status code | The numeric outcome is correct | Create returns 201, missing auth returns 401, missing ID returns 404 |
| JSON shape | Required keys and unsafe fields are checked | sessionId exists, password is not returned |
| Authentication | The caller identity is enforced | Bearer token, cookie, or API key |
| Negative test | Invalid input is intentionally tested | Wrong password, empty order, unsigned webhook |
| Contract test | Implementation matches the public API promise | OpenAPI-required fields still exist |
| Test data | Every run starts from predictable state | Local mock, seeded DB, disposable order ID |
The trap is thinking that 200 OK is enough. A response can be 200 and still be unusable because a field disappeared, an error envelope changed, a password leaked, or an unauthenticated request was accepted. Claude Code needs those expectations spelled out in the task.
Four Practical Use Cases
The sample in this article combines four use cases that cover most small API suites.
| Use case | Why it matters | What to assert |
|---|---|---|
| Login/session smoke test | Most product features depend on a valid session | 200, sessionId, user shape, no password |
| Order creation API | Orders affect revenue, inventory, support, and receipts | 201, Location header, total amount, detail fetch |
| Webhook endpoint | External services call it asynchronously and retry failures | Missing signature is 401, valid event is 202, duplicate event is safe |
| Regression test for a bug | A fixed bug should not silently return later | 400, 401, 404, and stable error JSON |
When you ask Claude Code to build the suite, name these flows directly. Webhooks deserve special care: a happy-path webhook test says very little unless it also checks missing signatures, duplicate event IDs, and unknown resources.
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"]
Runnable Node Fetch Example
The following file does not touch production services or any real database. It starts a tiny local HTTP server, then tests login, order creation, webhook handling, and negative cases with the built-in Node.js fetch. Save it as api-smoke.test.mjs and run it with Node.js 18 or newer.
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;
}
});
Run it with:
node api-smoke.test.mjs
You should see four ok lines. This is a safe mock, not a production test. In a real repository, replace makeApp() with your local app server, a staging URL, or Playwright’s request fixture. Keep the same assertions: status code, JSON shape, auth boundary, negative behavior, and no leaked secrets.
Prompt Claude Code Precisely
Claude Code needs a test target and failure rules. A useful prompt is concrete enough that the assistant cannot stop at one green happy path.
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.
If your API is described with OpenAPI, tell Claude Code to treat it as the contract. A contract test simply means: the public promise and the implementation result must agree.
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]
That small contract fragment gives Claude Code concrete obligations: creation returns 201, the body is wrapped in order, and the order object has required fields. During review, compare implementation, OpenAPI, tests, and README together.
Test Data and CI
API test stability usually fails at the data layer. If every test uses the same demo@example.com, the same order ID, and the same webhook event ID, parallel CI will create false failures. Use disposable IDs, reset the database between tests, or run against a local mock when the behavior is small enough to model safely.
| Strategy | Good for | Risk |
|---|---|---|
| Reset the DB per test run | Small services and CI | Never point it at production |
| Create disposable IDs | Orders, webhooks, partner events | Cleanup must be reliable |
| Read-only fixtures | Catalogs and public configuration | Weak for mutation tests |
| Mock external APIs | Payment, email, CRM | Mock-only suites miss production differences |
Run fast API smoke tests early in CI. They catch broken auth, JSON shape, and webhook regressions before heavier browser tests start.
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
If a test calls a staging service, add one more rule: never print secrets. Authorization headers, webhook signatures, session cookies, and API keys should be masked in logs. A passing test that leaks credentials is still a production risk.
Common Failure Modes
The first failure mode is only checking 200 OK. A response can be successful and still be wrong. Assert status, content type, required keys, and fields that must not exist.
The second is shared test data. A single demo user or webhook event ID makes tests order-dependent and flaky. Make each run independent.
The third is secrets in logs. Debugging output should redact tokens and signatures. Put that requirement directly in the Claude Code prompt.
The fourth is flaky external dependencies. Payment, email, and CRM APIs should usually be mocked for daily CI, then checked against staging in a smaller release gate.
The fifth is no negative testing. Missing auth, invalid payloads, missing IDs, forbidden roles, and unsigned webhooks are not edge cases. They are part of the API contract.
The sixth is relying only on mocks. Mocks are fast, but they can hide header differences, timeout behavior, status codes, and real error envelopes. Keep some contract or staging verification for high-risk integrations.
CTA and Result
API testing with Claude Code works best when you define the promise first: login stays valid, order creation remains stable, webhooks are verified, and old bugs get regression tests. Then make Claude Code implement the checks and run them in CI.
For teams adopting Claude Code, the bigger win is standardization: prompts, OpenAPI updates, review criteria, CI gates, and failure triage. ClaudeCodeLab can help through Claude Code training and consultation when you want this adapted to a real repository. Solo builders can start with the free cheatsheet and the prompt above.
Masa tested the workflow in this article with the local Node server above. The most useful result was compressing login, order creation, webhook verification, and regression coverage into one short command: node api-smoke.test.mjs. It caught more than a 200 OK check would: leaked password fields, missing auth, unsigned webhooks, invalid order payloads, and duplicate webhook events.
Free PDF: Claude Code Cheatsheet
Enter your email and download the one-page Claude Code cheatsheet for commands, review habits, and safe workflows.
We handle your data with care and never send spam.
Level up your Claude Code workflow
Start with the free PDF, use Gumroad guides when you need repeatable workflows, and book consultation when rollout or revenue paths need human judgment.
About the Author
Masa
Engineer focused on practical Claude Code workflows. Runs claudecode-lab.com, a 10-language technical media site.
Related Posts
Claude Code Obsidian to CLAUDE.md Workflow: Stop Re-explaining Context
Turn Obsidian working notes into concise CLAUDE.md operating notes that make Claude Code sessions easier to resume.
Claude Code Revenue CTA Routing: Send Articles to PDF, Gumroad, and Consultation
A Claude Code workflow for routing article readers to the free PDF, Gumroad products, or consultation by intent.
Claude Code Team Handoff Rules: Review Evidence, Permissions, Rollback, and Revenue Paths
A practical Claude Code handoff format for team review, proof, permission rules, rollback, free PDF, Gumroad, and consultation paths.
Related Products
50 Battle-Tested Claude Code Prompt Templates
Copy, paste, ship. 50 production-ready prompts.
Use proven prompts for code review, refactoring, testing, documentation, debugging, architecture, and incident response.
The Complete Claude Code Setup & Configuration Guide
From install to team-ready workflow.
A practical guide to installation, CLAUDE.md, hooks, MCP servers, permissions, IDE setup, and CI/CD workflows.