Tests d'API avec Claude Code : guide pratique d'automatisation
Guide pratique des tests API avec Claude Code : smoke tests, auth, JSON, contrats, CI et exemples exécutables.
Un test d’API vérifie qu’un serveur respecte son contrat avant même d’ouvrir une page dans le navigateur. Il envoie des requêtes HTTP directes et contrôle que la connexion fonctionne, qu’une commande peut être créée, que les erreurs sont explicites, que l’authentification est appliquée et que la forme du JSON reste compatible avec les clients.
Si vous demandez simplement à Claude Code « écris des tests d’API », le résultat est souvent trop léger : un chemin heureux, une assertion 200 OK et rien sur les cas dangereux. Pour protéger un vrai produit, il faut couvrir les smoke tests, les codes de statut, le JSON shape, l’authentification, les tests négatifs, les tests de contrat, les données de test et le CI.
Ce guide donne un flux concret, adapté aux débutants. Pour compléter, lisez aussi le guide de conception d’API, la stratégie de versioning API et le workflow de diagnostic d’erreurs.
Les références officielles sont importantes. Si votre projet utilise Playwright, partez de Playwright API testing. L’exemple ci-dessous utilise fetch, documenté par MDN Fetch API. Pour rendre les contrats vérifiables, utilisez l’OpenAPI Specification.
Ce qu’un test d’API doit prouver
Le but n’est pas d’accumuler des tests, mais de savoir vite si un contrat public est cassé. Les tests E2E avec navigateur restent utiles, car ils couvrent le parcours utilisateur complet. En revanche, lorsqu’ils échouent, il faut souvent démêler UI, réseau, session, serveur, données et dépendances externes. Un test d’API saute l’interface et vérifie directement la frontière serveur.
| Point de contrôle | Explication simple | Exemple |
|---|---|---|
| Smoke test | Vérifier que le minimum fonctionne | /health renvoie 204, login renvoie 200 |
| Code de statut | Résultat exprimé en HTTP | Création 201, sans auth 401, introuvable 404 |
| JSON shape | Champs requis et champs interdits | sessionId existe, password n’est pas renvoyé |
| Authentification | L’identité de l’appelant est contrôlée | Bearer token, cookie ou API key |
| Test négatif | Envoyer volontairement une mauvaise entrée | Mauvais mot de passe, commande vide, webhook non signé |
| Test de contrat | La réponse suit la promesse publique | Champs OpenAPI requis toujours présents |
| Données de test | Chaque exécution démarre d’un état prévisible | Mock local, base réinitialisée, ID jetable |
Le piège classique est de ne regarder que 200 OK. Une réponse peut réussir tout en étant inutilisable : champ supprimé, enveloppe d’erreur modifiée, secret renvoyé ou requête non authentifiée acceptée. Ces attentes doivent être écrites dans le prompt donné à Claude Code.
Quatre cas d’usage pratiques
L’exemple de cet article regroupe quatre flux qui couvrent la majorité des petites suites de tests API.
| Cas d’usage | Pourquoi c’est important | Assertions |
|---|---|---|
| Login et session en smoke test | La plupart des fonctionnalités dépendent d’une session valide | 200, sessionId, forme utilisateur, pas de mot de passe |
| API de création de commande | Impact direct sur revenus, stock, reçus et support | 201, en-tête Location, total, lecture du détail |
| Endpoint webhook | Des services externes l’appellent de façon asynchrone | Sans signature 401, événement valide 202, doublon sûr |
| Test de régression d’un bug | Un bug corrigé ne doit pas revenir sans alerte | 400, 401, 404 et JSON d’erreur stable |
Nommez ces flux explicitement dans la demande à Claude Code. Pour les webhooks, le chemin heureux ne suffit pas : ajoutez signature absente, event ID dupliqué et ressource inconnue.
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"]
Exemple exécutable avec Node fetch
Ce fichier ne touche ni production ni base de données réelle. Il lance un petit serveur HTTP local, puis le teste avec fetch intégré à Node.js. Enregistrez-le sous api-smoke.test.mjs et lancez-le avec Node.js 18 ou plus récent.
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;
}
});
Commande d’exécution :
node api-smoke.test.mjs
Vous devez voir quatre lignes ok. C’est un mock local sûr, pas un test contre la production. Dans un vrai dépôt, remplacez makeApp() par votre serveur local, une URL de staging ou le fixture request de Playwright. Gardez les assertions sur le statut, le JSON shape, l’authentification, les cas négatifs et les secrets qui ne doivent pas fuiter.
Prompt à donner à Claude Code
Claude Code doit recevoir la cible et les règles d’échec. Un prompt précis évite un test limité au chemin heureux.
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.
Si l’API possède un fichier OpenAPI, dites à Claude Code de le traiter comme le contrat. Un test de contrat vérifie simplement que la promesse publique et la réponse réelle restent alignées.
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]
Ce fragment fixe déjà des obligations : création en 201, réponse enveloppée dans order, champs requis présents. Relisez ensemble l’implémentation, OpenAPI, les tests et le README.
Données de test et CI
La stabilité des tests d’API dépend souvent des données. Si tout le monde utilise demo@example.com, le même ID de commande et le même event ID webhook, le CI parallèle deviendra fragile. Utilisez des ID jetables, réinitialisez la base par exécution ou modélisez le flux avec un mock local quand c’est suffisant.
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
Placez les smoke tests d’API tôt dans le CI. Ils détectent l’authentification cassée, les formes JSON instables et les régressions webhook avant les tests navigateur plus lents. Si vous appelez un staging, imposez aussi une règle de logs : ne jamais afficher tokens, cookies, signatures webhook ou API keys.
Pièges fréquents
Premier piège : vérifier seulement 200 OK. Il faut aussi tester les champs requis, les champs interdits, le format d’erreur et les codes des échecs.
Deuxième piège : partager les données de test. Un utilisateur global ou un event ID global rend les tests dépendants de l’ordre.
Troisième piège : écrire les secrets dans les logs. Les en-têtes Authorization, cookies et signatures doivent être masqués.
Quatrième piège : dépendre d’un service externe instable. Paiement, email et CRM doivent souvent être mockés en CI quotidien, puis vérifiés en staging avant release.
Cinquième piège : oublier les tests négatifs. Sans auth, payload invalide, ID inconnu, rôle interdit et webhook non signé font partie du contrat.
Sixième piège : ne faire confiance qu’aux mocks. Les mocks sont rapides, mais ils cachent parfois les différences de headers, timeouts, codes et enveloppes d’erreur.
Conclusion et CTA
Pour obtenir de bons tests d’API avec Claude Code, commencez par définir la promesse à protéger : login stable, création de commande fiable, webhook vérifié et bug ancien couvert par régression. Ensuite seulement, demandez le code et la commande CI.
En équipe, le vrai gain vient de la standardisation : prompts, OpenAPI, critères de revue, gates CI et diagnostic des échecs. ClaudeCodeLab peut vous aider via Claude Code training and consultation pour adapter ce flux à un dépôt réel. Si vous travaillez seul, commencez par la fiche gratuite et le prompt de cet article.
Masa a testé ce flux avec le serveur Node local ci-dessus. Le résultat le plus utile a été de regrouper login, création de commande, webhook et régressions dans une seule commande : node api-smoke.test.mjs. Elle détecte plus qu’un simple 200 OK : fuite de password, absence d’auth, webhook non signé, payload invalide et événement webhook dupliqué.
PDF gratuit: cheatsheet Claude Code
Saisissez votre email et téléchargez une page avec commandes, habitudes de review et workflow sûr.
Nous protégeons vos données et n'envoyons pas de spam.
À propos de l'auteur
Masa
Ingénieur spécialisé dans les workflows pratiques avec Claude Code.
Articles liés
Workflow Obsidian vers CLAUDE.md avec Claude Code
Transformer des notes Obsidian en notes CLAUDE.md concises pour reprendre les sessions sans réexpliquer.
Claude Code Revenue CTA Routing : relier articles, PDF, Gumroad et consultation
Un workflow Claude Code pour orienter les lecteurs vers PDF gratuit, Gumroad ou consultation selon l'intention.
Règles de handoff Claude Code en équipe: preuves, permissions, rollback et revenus
Un format concret pour transmettre un travail Claude Code avec preuves, permissions, rollback, PDF gratuit, Gumroad et consultation.