API-Tests mit Claude Code: Praxisleitfaden zur Automatisierung
Praxisleitfaden für API-Tests mit Claude Code: Smoke Tests, Auth, JSON, Verträge, CI und lauffähige Beispiele.
API-Tests prüfen, ob ein Server sein öffentliches Versprechen einhält, bevor ein Browser überhaupt eine Seite öffnet. Sie senden direkte HTTP-Anfragen und kontrollieren, ob Login funktioniert, ob Bestellungen erstellt werden, ob Fehler verständlich sind, ob Authentifizierung greift und ob die JSON-Struktur noch zu Frontend, Mobile-App oder Partnerintegration passt.
Wenn man Claude Code nur bittet, “API-Tests zu schreiben”, entsteht oft ein zu dünnes Ergebnis: ein Happy Path, eine 200-OK-Assertion und kaum Schutz gegen echte Produktionsfehler. Ein nützliches Testset braucht Smoke Tests, Statuscodes, JSON Shape, Authentifizierung, negative Tests, Contract Tests, Testdaten und CI.
Dieser Leitfaden zeigt einen anfängerfreundlichen, aber praktischen Workflow. Für verwandte Themen siehe den API-Design-Leitfaden, die API-Versionierungsstrategie und den Workflow zur Fehlerdiagnose.
Offizielle Referenzen sollten Teil der Arbeitsgrundlage sein. Wenn euer Projekt Playwright nutzt, ist Playwright API testing relevant. Das Beispiel unten nutzt fetch, beschrieben bei MDN Fetch API. Für prüfbare API-Verträge ist die OpenAPI Specification die Basis.
Was API-Tests beweisen sollen
Das Ziel ist nicht eine hohe Testzahl, sondern frühe Klarheit darüber, ob ein Vertrag gebrochen wurde. E2E-Tests im Browser bleiben wertvoll, weil sie die komplette Benutzerreise prüfen. Bei Fehlern ist die Ursache aber oft schwer zu trennen: UI, Netzwerk, Session, Backend, Testdaten oder externer Anbieter. API-Tests überspringen die UI und prüfen direkt die Servergrenze.
| Prüfung | Einfache Bedeutung | Beispiel |
|---|---|---|
| Smoke Test | Minimaler Lebenszeichen-Test | /health liefert 204, Login liefert 200 |
| Statuscode | Ergebnis als HTTP-Zahl | Erstellung 201, ohne Auth 401, nicht gefunden 404 |
| JSON Shape | Pflichtfelder und verbotene Felder | sessionId existiert, password wird nicht zurückgegeben |
| Authentifizierung | Identität des Aufrufers wird geprüft | Bearer Token, Cookie oder API Key |
| Negativer Test | Fehlerhafte Eingaben bewusst senden | Falsches Passwort, leere Bestellung, unsignierter Webhook |
| Contract Test | Implementierung passt zum öffentlichen Vertrag | OpenAPI-Pflichtfelder sind weiterhin vorhanden |
| Testdaten | Jeder Lauf startet aus einem bekannten Zustand | Lokaler Mock, resetbare DB, Wegwerf-ID |
Der häufigste Fehler ist, nur 200 OK zu prüfen. Eine Antwort kann erfolgreich sein und trotzdem kaputt: ein Feld fehlt, ein Error Envelope wurde geändert, ein Secret wird geleakt oder ein unauthentifizierter Request wird akzeptiert. Diese Erwartungen gehören explizit in den Prompt an Claude Code.
Vier praktische Use Cases
Das Beispiel in diesem Artikel fasst vier API-Flows zusammen, die in kleinen Test-Suites besonders viel Schutz bieten.
| Use Case | Warum wichtig | Was prüfen |
|---|---|---|
| Login und Session als Smoke Test | Fast alle Funktionen hängen an einer gültigen Session | 200, sessionId, User-Shape, kein Passwort |
| Bestell-API | Betrifft Umsatz, Bestand, Belege und Support | 201, Location-Header, Summe, Detailabruf |
| Webhook-Endpunkt | Externe Services rufen ihn asynchron auf und wiederholen | Ohne Signatur 401, gültiges Event 202, Duplikat sicher |
| Regressionstest für Bug | Ein behobener Fehler darf nicht unbemerkt zurückkehren | 400, 401, 404 und stabiles Fehler-JSON |
Benennen Sie diese Flows direkt in der Aufgabe an Claude Code. Gerade bei Webhooks ist ein Happy Path zu wenig. Fehlende Signatur, doppelte Event-ID und unbekannte Ressource müssen Teil des Tests sein.
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"]
Ausführbares Beispiel mit Node fetch
Diese Datei spricht keine Produktion und keine echte Datenbank an. Sie startet einen kleinen lokalen HTTP-Server und testet ihn mit dem in Node.js eingebauten fetch. Speichern Sie die Datei als api-smoke.test.mjs und verwenden Sie Node.js 18 oder neuer.
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;
}
});
Ausführen:
node api-smoke.test.mjs
Bei Erfolg erscheinen vier ok-Zeilen. Das ist ein sicherer lokaler Mock, kein Test gegen Produktion. In einem echten Repository ersetzen Sie makeApp() durch den lokalen App-Server, eine Staging-URL oder Playwrights request-Fixture. Die Assertions bleiben gleich: Statuscode, JSON Shape, Auth-Grenze, negative Fälle und keine geleakten Secrets.
Guter Prompt für Claude Code
Claude Code braucht Ziel und Fehlregeln. Ein konkreter Prompt verhindert, dass nur der Happy Path entsteht.
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.
Wenn eine OpenAPI-Datei existiert, schreiben Sie dazu: “Treat OpenAPI as the contract.” Ein Contract Test heißt nur, dass öffentliche Spezifikation und tatsächliche Antwort übereinstimmen.
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]
Dieses kleine Fragment legt bereits fest: Erstellung liefert 201, der Body ist in order eingepackt, Pflichtfelder müssen vorhanden sein. Prüfen Sie Implementierung, OpenAPI, Tests und README gemeinsam.
Testdaten und CI
Die Stabilität von API-Tests hängt stark an Daten. Wenn alle Tests demo@example.com, dieselbe Bestell-ID und dieselbe Webhook-Event-ID nutzen, wird paralleles CI instabil. Verwenden Sie Wegwerf-IDs, setzen Sie Datenbanken pro Lauf zurück oder modellieren Sie kleine Flows mit einem lokalen 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
Schnelle API-Smoke-Tests gehören früh in die CI-Pipeline. So fallen Auth-, JSON- und Webhook-Probleme vor langsamen Browsertests auf. Wenn Staging aufgerufen wird, muss außerdem gelten: Tokens, Cookies, Webhook-Signaturen und API Keys niemals in Logs schreiben.
Häufige Fehler
Erstens: nur 200 OK prüfen. Pflichtfelder, verbotene Felder, Fehlerformat und Statuscodes für Fehler gehören dazu.
Zweitens: gemeinsame Testdaten. Ein globaler Demo-User macht Tests abhängig von Reihenfolge und Parallelität.
Drittens: Secrets in Logs. Authorization, Cookies und Webhook-Signaturen müssen maskiert werden.
Viertens: instabile externe Abhängigkeiten. Zahlung, E-Mail und CRM sollten im täglichen CI meist gemockt und vor Release gezielt in Staging geprüft werden.
Fünftens: keine negativen Tests. Fehlende Auth, ungültige Payloads, unbekannte IDs, verbotene Rollen und unsignierte Webhooks sind Teil des Vertrags.
Sechstens: nur Mocks glauben. Mocks sind schnell, können aber echte Header, Timeouts, Error Envelopes und Statuscodes verstecken.
Fazit und CTA
Gute API-Tests mit Claude Code beginnen mit der geschützten Zusage: Login bleibt gültig, Bestellungen werden stabil erstellt, Webhooks werden geprüft und alte Bugs sind als Regression abgedeckt. Danach lässt man Claude Code Code und CI-Befehl ergänzen.
Für Teams ist der größte Gewinn Standardisierung: Prompts, OpenAPI-Updates, Review-Kriterien, CI-Gates und Fehlerdiagnose. ClaudeCodeLab unterstützt über Claude Code training and consultation bei der Anpassung an echte Repositories. Einzelne Entwickler können mit dem kostenlosen Cheatsheet und dem Prompt aus diesem Artikel starten.
Masa hat den Workflow mit dem lokalen Node-Server oben getestet. Der praktische Effekt war, Login, Bestellerstellung, Webhook-Prüfung und Regressionen in eine kurze Anweisung zu bringen: node api-smoke.test.mjs. Das erkennt mehr als 200 OK: geleakte password-Felder, fehlende Auth, unsignierte Webhooks, ungültige Payloads und doppelte Webhook-Events.
Kostenloses PDF: Claude-Code-Cheatsheet
E-Mail eintragen und eine Seite mit Befehlen, Review-Gewohnheiten und sicheren Workflows herunterladen.
Wir schützen Ihre Daten und senden keinen Spam.
Über den Autor
Masa
Engineer für praktische Claude-Code-Workflows und Team-Einführung.
Ähnliche Artikel
Claude Code Workflow von Obsidian zu CLAUDE.md
Obsidian-Arbeitsnotizen in CLAUDE.md-Betriebsnotizen verwandeln und Kontext nicht ständig neu erklären.
Claude Code Revenue CTA Routing: Artikel zu PDF, Gumroad und Beratung führen
Ein Claude-Code-Ablauf, der Leser nach Absicht zu Gratis-PDF, Gumroad oder Beratung führt.
Claude-Code-Team-Handoff-Regeln: Belege, Berechtigungen, Rollback und Umsatzpfade
Ein praktisches Claude-Code-Handoff für Review-Belege, Berechtigungen, Rollback, Gratis-PDF, Gumroad und Beratung.