Use Cases (Mis à jour: 02/06/2026)

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.

Tests d'API avec Claude Code : guide pratique d'automatisation

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ôleExplication simpleExemple
Smoke testVérifier que le minimum fonctionne/health renvoie 204, login renvoie 200
Code de statutRésultat exprimé en HTTPCréation 201, sans auth 401, introuvable 404
JSON shapeChamps requis et champs interditssessionId existe, password n’est pas renvoyé
AuthentificationL’identité de l’appelant est contrôléeBearer token, cookie ou API key
Test négatifEnvoyer volontairement une mauvaise entréeMauvais mot de passe, commande vide, webhook non signé
Test de contratLa réponse suit la promesse publiqueChamps OpenAPI requis toujours présents
Données de testChaque exécution démarre d’un état prévisibleMock 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’usagePourquoi c’est importantAssertions
Login et session en smoke testLa plupart des fonctionnalités dépendent d’une session valide200, sessionId, forme utilisateur, pas de mot de passe
API de création de commandeImpact direct sur revenus, stock, reçus et support201, en-tête Location, total, lecture du détail
Endpoint webhookDes services externes l’appellent de façon asynchroneSans signature 401, événement valide 202, doublon sûr
Test de régression d’un bugUn bug corrigé ne doit pas revenir sans alerte400, 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é.

#Claude Code #API testing #automation #testing #quality assurance
Gratuit

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.

Masa

À propos de l'auteur

Masa

Ingénieur spécialisé dans les workflows pratiques avec Claude Code.