Use Cases (Diperbarui: 2/6/2026)

API Testing dengan Claude Code: Panduan Praktis Otomasi

Panduan praktis API testing dengan Claude Code: smoke test, auth, JSON, contract test, CI, dan contoh runnable.

API Testing dengan Claude Code: Panduan Praktis Otomasi

API testing memeriksa apakah server menepati kontraknya sebelum layar browser dibuka. Test mengirim HTTP request langsung dan memastikan login berjalan, order bisa dibuat, error response jelas, autentikasi benar-benar diterapkan, dan bentuk JSON masih cocok dengan kebutuhan frontend, aplikasi mobile, atau integrasi partner.

Kalau Anda hanya meminta Claude Code “buatkan API test”, hasil awal sering terlalu tipis: satu happy path, satu assertion 200 OK, lalu selesai. Untuk melindungi produk nyata, test harus mencakup smoke test, status code, JSON shape, authentication, negative test, contract test, test data, dan CI.

Panduan ini ditulis agar mudah diikuti pemula, tetapi tetap praktis untuk proyek produksi. Untuk topik terkait, baca juga panduan desain API, strategi versioning API, dan workflow diagnosis error.

Gunakan dokumentasi resmi sebagai rujukan. Jika proyek Anda memakai Playwright, lihat Playwright API testing. Contoh di bawah memakai fetch, yang dijelaskan di MDN Fetch API. Untuk kontrak API yang bisa direview, gunakan OpenAPI Specification.

Apa yang Harus Dibuktikan API Test

Tujuan API test bukan sekadar jumlah test yang banyak. Tujuannya adalah mengetahui lebih cepat apakah kontrak publik rusak. E2E test di browser tetap penting karena menguji perjalanan pengguna lengkap, tetapi saat gagal, penyebabnya bisa UI, jaringan, session, backend, data, atau layanan eksternal. API test melewati UI dan langsung memeriksa batas server.

PemeriksaanArti sederhanaContoh
Smoke testLayanan minimal masih hidup/health mengembalikan 204, login 200
Status codeHasil dalam angka HTTPCreate 201, tanpa auth 401, tidak ditemukan 404
JSON shapeField wajib dan field terlarangAda sessionId, tidak ada password
AuthenticationIdentitas pemanggil dicekBearer token, cookie, API key
Negative testSengaja mengirim input salahPassword salah, order kosong, webhook tanpa signature
Contract testImplementasi cocok dengan janji publikField wajib OpenAPI tetap ada
Test dataSetiap run mulai dari kondisi terprediksiMock lokal, reset DB, ID order sekali pakai

Kesalahan paling umum adalah hanya mengecek 200 OK. Response bisa 200 tetapi tetap salah: field hilang, format error berubah, secret bocor, atau request tanpa auth diterima. Harapan seperti ini harus ditulis jelas dalam prompt untuk Claude Code.

Empat Use Case Praktis

Contoh pada artikel ini menggabungkan empat alur yang sering cukup untuk API test suite kecil.

Use caseAlasan pentingYang dicek
Login dan session smoke testHampir semua fitur bergantung pada session valid200, sessionId, bentuk user, tanpa password
API pembuatan orderBerhubungan dengan revenue, stok, receipt, support201, header Location, total, fetch detail
Endpoint webhookLayanan eksternal memanggil async dan bisa retryTanpa signature 401, event valid 202, duplicate aman
Regression test untuk bugBug yang sudah diperbaiki tidak kembali diam-diam400, 401, 404, error JSON stabil

Saat meminta Claude Code, sebutkan alur ini secara eksplisit. Untuk webhook, happy path saja tidak cukup. Tambahkan signature hilang, event ID duplikat, dan order ID yang tidak ada.

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"]

Contoh Node fetch yang Bisa Langsung Jalan

File ini tidak menyentuh production service atau database nyata. Ia menjalankan HTTP server lokal kecil, lalu mengujinya dengan fetch bawaan Node.js. Simpan sebagai api-smoke.test.mjs dan jalankan dengan Node.js 18 atau lebih baru.

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;
  }
});

Jalankan:

node api-smoke.test.mjs

Jika berhasil, Anda akan melihat empat baris ok. Ini adalah mock lokal yang aman, bukan test production. Di repository nyata, ganti makeApp() dengan app server lokal, URL staging, atau fixture request dari Playwright. Assertion tetap sama: status code, JSON shape, batas auth, negative behavior, dan tidak ada secret yang bocor.

Prompt untuk Claude Code

Claude Code membutuhkan target dan aturan kegagalan. Prompt konkret mencegah hasil yang hanya berisi satu 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.

Jika ada file OpenAPI, minta Claude Code memperlakukannya sebagai kontrak. Contract test berarti mengecek apakah janji publik dan response aktual tetap selaras.

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]

Fragmen kecil ini sudah memberi kewajiban: create mengembalikan 201, body dibungkus dalam order, dan field wajib harus ada. Review implementasi, OpenAPI, test, dan README secara bersamaan.

Test Data dan CI

Stabilitas API test sangat bergantung pada data. Jika semua test memakai demo@example.com, ID order yang sama, dan event ID webhook yang sama, CI paralel akan rapuh. Gunakan ID sekali pakai, reset database per run, atau modelkan alur dengan mock lokal jika cukup aman.

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

Letakkan smoke test API di awal CI. Auth, bentuk JSON, dan regresi webhook akan gagal lebih cepat sebelum browser test yang lebih berat. Jika memanggil staging, tambahkan aturan log: jangan pernah mencetak token, cookie, webhook signature, atau API key.

Kesalahan Umum

Pertama, hanya mengecek 200 OK. Field wajib, field yang tidak boleh ada, format error, dan status untuk failure juga harus dicek.

Kedua, test data bersama. Satu user demo global membuat test bergantung pada urutan dan paralelisme.

Ketiga, secret muncul di log. Authorization, cookie, dan webhook signature harus dimasking.

Keempat, dependency eksternal yang flaky. Payment, email, dan CRM biasanya dimock di CI harian, lalu dicek di staging sebelum release.

Kelima, tidak ada negative test. Missing auth, payload invalid, ID tidak ada, role terlarang, dan webhook tanpa signature adalah bagian dari kontrak API.

Keenam, hanya percaya mock. Mock cepat, tetapi bisa menyembunyikan perbedaan header, timeout, status code, dan error envelope di layanan nyata.

Penutup dan CTA

API testing dengan Claude Code berhasil ketika promise ditulis lebih dulu: login tetap valid, order creation stabil, webhook diverifikasi, dan bug lama memiliki regression test. Setelah itu, minta Claude Code menulis code dan command CI.

Untuk tim, nilai terbesarnya adalah standardisasi: prompt, update OpenAPI, kriteria review, CI gate, dan diagnosis failure. ClaudeCodeLab dapat membantu lewat Claude Code training and consultation untuk menyesuaikan workflow ini ke repository nyata. Developer individual bisa mulai dari cheatsheet gratis dan prompt di artikel ini.

Masa menguji workflow ini dengan server Node lokal di atas. Hasil praktisnya: login, pembuatan order, verifikasi webhook, dan regression coverage bisa digabung dalam satu command pendek, node api-smoke.test.mjs. Command ini menangkap lebih banyak daripada 200 OK: password bocor, auth hilang, webhook tanpa signature, payload order invalid, dan event webhook duplikat.

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

PDF gratis: cheatsheet Claude Code

Masukkan email dan unduh satu halaman berisi command, kebiasaan review, dan workflow aman.

Kami menjaga datamu dan tidak mengirim spam.

Masa

Tentang penulis

Masa

Engineer yang berfokus pada workflow Claude Code praktis dan adopsi tim.