Kontrak Snapshot API di Bun berguna untuk mendeteksi perubahan respons yang tidak disengaja sebelum sampai ke klien. Pendekatan ini efektif jika Anda ingin mengunci bentuk JSON, status code, dan header penting, tetapi tetap perlu disiplin agar snapshot tidak berubah menjadi file besar yang disetujui tanpa review.

Di Bun, snapshot testing bisa dipakai untuk memverifikasi kontrak respons API dengan cepat. Kuncinya bukan sekadar memanggil toMatchSnapshot, melainkan menormalisasi field dinamis, hanya menyimpan bagian respons yang relevan, dan memilih kapan snapshot cocok dibanding assertion eksplisit.

Kapan snapshot cocok untuk kontrak API

Snapshot cocok saat Anda ingin memverifikasi bentuk respons yang cukup kaya dan perubahan kecil pada struktur harus terlihat jelas saat review. Contohnya:

  • Endpoint JSON dengan banyak field yang stabil.
  • Respons yang harus menjaga kompatibilitas dengan frontend atau integrasi pihak lain.
  • Header tertentu yang penting secara fungsional, misalnya content-type atau header cache.
  • Payload error yang harus konsisten formatnya.

Snapshot kurang cocok jika data yang diverifikasi hanya sedikit dan mudah ditulis sebagai assertion biasa. Untuk kasus seperti itu, assertion eksplisit biasanya lebih jelas dan lebih tahan lama.

Kapan lebih aman memakai assertion eksplisit

Gunakan assertion eksplisit jika:

  • Anda hanya perlu memastikan status code tertentu.
  • Anda hanya peduli 2-3 field penting.
  • Field dinamis terlalu banyak dan snapshot akan sering berubah.
  • Urutan elemen memang tidak dijamin dan perubahan urutan tidak bermakna.
  • Anda ingin pesan kegagalan yang sangat spesifik.

Prinsip praktisnya: semakin kecil kontraknya, semakin cocok assertion eksplisit. Semakin kaya bentuk responsnya, semakin masuk akal snapshot, asalkan dinormalisasi dengan benar.

Contoh endpoint sederhana di Bun

Berikut contoh server HTTP sederhana di Bun yang mengembalikan JSON. Contoh ini sengaja memasukkan field dinamis seperti id, timestamp, dan nonce karena inilah sumber umum snapshot yang rapuh.

const app = Bun.serve({
  port: 3000,
  async fetch(req) {
    const url = new URL(req.url);

    if (url.pathname === "/api/orders/42") {
      const body = {
        data: {
          id: crypto.randomUUID(),
          type: "order",
          attributes: {
            orderNumber: "ORD-42",
            amount: 125000,
            currency: "IDR",
            createdAt: new Date().toISOString(),
            items: [
              { sku: "SKU-2", qty: 1 },
              { sku: "SKU-1", qty: 2 }
            ]
          }
        },
        meta: {
          requestId: crypto.randomUUID(),
          nonce: Math.random().toString(36).slice(2)
        }
      };

      return new Response(JSON.stringify(body), {
        status: 200,
        headers: {
          "content-type": "application/json; charset=utf-8",
          "cache-control": "no-store"
        }
      });
    }

    return new Response(JSON.stringify({ error: "Not Found" }), {
      status: 404,
      headers: { "content-type": "application/json; charset=utf-8" }
    });
  }
});

console.log(`Server berjalan di http://localhost:${app.port}`);

Kalau endpoint seperti ini langsung di-snapshot apa adanya, test akan sering gagal karena id, requestId, nonce, dan createdAt selalu berubah.

Struktur test snapshot yang tidak rapuh

Pola yang disarankan adalah memecah verifikasi menjadi tiga lapisan:

  1. Assertion eksplisit untuk status code dan header penting.
  2. Normalisasi respons untuk menghapus noise dari field dinamis.
  3. Snapshot untuk bentuk akhir payload yang sudah stabil.

Contoh test di Bun:

import { describe, expect, test } from "bun:test";

function sortObject(value) {
  if (Array.isArray(value)) {
    return value.map(sortObject);
  }

  if (value && typeof value === "object") {
    return Object.keys(value)
      .sort()
      .reduce((acc, key) => {
        acc[key] = sortObject(value[key]);
        return acc;
      }, {});
  }

  return value;
}

function normalizeApiResponse(payload) {
  const cloned = structuredClone(payload);

  if (cloned?.data?.id) cloned.data.id = "<id>";
  if (cloned?.meta?.requestId) cloned.meta.requestId = "<request-id>";
  if (cloned?.meta?.nonce) cloned.meta.nonce = "<nonce>";
  if (cloned?.data?.attributes?.createdAt) {
    cloned.data.attributes.createdAt = "<timestamp>";
  }

  if (Array.isArray(cloned?.data?.attributes?.items)) {
    cloned.data.attributes.items.sort((a, b) => a.sku.localeCompare(b.sku));
  }

  return sortObject(cloned);
}

describe("GET /api/orders/42", () => {
  test("kontrak respons tetap stabil", async () => {
    const res = await fetch("http://localhost:3000/api/orders/42");

    expect(res.status).toBe(200);
    expect(res.headers.get("content-type")).toContain("application/json");
    expect(res.headers.get("cache-control")).toBe("no-store");

    const json = await res.json();
    const normalized = normalizeApiResponse(json);

    expect(normalized).toMatchSnapshot();
  });
});

Pendekatan ini bekerja karena snapshot hanya menangkap kontrak yang bermakna, bukan nilai acak yang memang berubah setiap request.

Mengapa status code dan header sebaiknya tidak hanya di-snapshot

Walau status code dan header bisa ikut masuk ke snapshot, lebih aman menulis assertion eksplisit untuk elemen-elemen berikut:

  • Status code, karena perubahan dari 200 ke 201 atau 204 sering punya makna kontrak yang jelas.
  • Content-Type, karena klien bisa bergantung pada tipe respons.
  • Cache-Control atau header keamanan tertentu, karena salah konfigurasi di sini sering kritis.

Kalau hanya mengandalkan snapshot, perubahan penting bisa tertutup di tengah diff besar. Assertion eksplisit membuat kegagalan langsung terlihat.

Helper normalizer untuk field dinamis dan urutan data

Bagian tersulit dari snapshot kontrak API biasanya bukan di tool-nya, melainkan di normalisasi data. Anda perlu menentukan mana yang merupakan kontrak dan mana yang hanya detail runtime.

Field yang umumnya perlu dinormalisasi

  • timestamp: createdAt, updatedAt, expiresAt
  • identifier acak: UUID, request ID, trace ID
  • nonce, token sementara, signature
  • URL bertanda tangan atau field yang mengandung hash dinamis
  • Urutan array jika API memang tidak menjamin ordering

Contoh helper yang lebih generik:

function deepNormalize(value) {
  if (Array.isArray(value)) {
    return value.map(deepNormalize);
  }

  if (!value || typeof value !== "object") {
    return value;
  }

  const out = {};

  for (const [key, raw] of Object.entries(value)) {
    let normalized = raw;

    if (["id", "requestId", "traceId"].includes(key) && typeof raw === "string") {
      normalized = `<${key}>`;
    } else if (["createdAt", "updatedAt", "timestamp", "expiresAt"].includes(key)) {
      normalized = "<timestamp>";
    } else if (["nonce", "signature"].includes(key)) {
      normalized = `<${key}>`;
    } else {
      normalized = deepNormalize(raw);
    }

    out[key] = normalized;
  }

  return out;
}

function normalizeAndSortOrderResponse(payload) {
  const cloned = structuredClone(payload);

  if (Array.isArray(cloned?.data?.attributes?.items)) {
    cloned.data.attributes.items.sort((a, b) => a.sku.localeCompare(b.sku));
  }

  return sortObject(deepNormalize(cloned));
}

Namun jangan membuat normalizer terlalu agresif. Jika semua field berubah menjadi placeholder, snapshot kehilangan nilai. Tujuannya adalah menghapus ketidakstabilan, bukan menghapus kontrak.

Kesalahan umum saat normalisasi

  • Mengganti seluruh subtree menjadi placeholder sehingga struktur penting ikut hilang.
  • Menyortir array yang seharusnya punya urutan bermakna, misalnya timeline atau ranking.
  • Menormalkan field yang justru perlu dijaga nilainya, misalnya currency, status, atau role.
  • Mengandalkan data produksi atau data yang berubah-ubah sebagai fixture test.

Memilih antara snapshot penuh, snapshot parsial, dan assertion eksplisit

Tidak semua endpoint perlu pola yang sama. Pilih sesuai karakter kontraknya.

Snapshot penuh

Cocok untuk respons kecil sampai menengah yang strukturnya stabil dan memang ingin diawasi menyeluruh. Kelebihannya, perubahan struktural mudah terlihat. Kekurangannya, diff bisa ramai jika payload terlalu besar.

Snapshot parsial

Sering kali ini pilihan terbaik. Ambil hanya bagian yang penting untuk kontrak, misalnya:

const contractView = {
  status: res.status,
  headers: {
    contentType: res.headers.get("content-type"),
    cacheControl: res.headers.get("cache-control")
  },
  body: normalizeApiResponse(json)
};

expect(contractView).toMatchSnapshot();

Dengan cara ini, Anda tidak perlu menyimpan seluruh objek respons atau header yang tidak relevan.

Assertion eksplisit

Pilih ini untuk validasi yang sempit dan penting. Contohnya:

expect(json.data.type).toBe("order");
expect(json.data.attributes.currency).toBe("IDR");
expect(Array.isArray(json.data.attributes.items)).toBe(true);

Pola yang sering paling sehat adalah gabungan assertion eksplisit + snapshot parsial.

Pola review snapshot agar tidak menyetujui regresi secara buta

Masalah terbesar snapshot testing biasanya bukan di penulisan test, melainkan pada proses review. Snapshot bisa menjadi jebakan jika setiap perubahan langsung di-approve tanpa membaca diff.

Praktik review yang disarankan

  • Jangan update snapshot otomatis di CI. CI sebaiknya gagal jika snapshot berubah.
  • Wajib review diff snapshot di pull request. Anggap file snapshot sebagai bagian dari kontrak publik.
  • Minta alasan perubahan kontrak. Jika struktur respons berubah, harus ada konteks pada PR atau changelog internal.
  • Pisahkan refactor internal dari perubahan kontrak. Jangan campur banyak perubahan besar dalam satu PR snapshot.
  • Gunakan snapshot yang kecil dan terfokus. Diff menjadi lebih mudah dibaca.

Aturan sederhana: jika reviewer tidak bisa menjelaskan mengapa snapshot berubah, perubahan itu belum siap disetujui.

Tanda snapshot sudah terlalu rapuh atau terlalu besar

  • Sering berubah padahal perilaku API tidak berubah.
  • File snapshot berisi banyak field yang tidak pernah dipakai klien.
  • Reviewer cenderung melewatkan diff karena terlalu panjang.
  • Test gagal acak akibat urutan data atau timestamp.

Jika salah satu gejala ini muncul, kecilkan cakupan snapshot dan tambah assertion eksplisit pada bagian yang benar-benar penting.

Workflow CI untuk kontrak snapshot API di Bun

Tujuan CI adalah memastikan perubahan kontrak tidak lolos tanpa sengaja. Alur minimal yang praktis:

  1. Jalankan server atau environment test.
  2. Jalankan seluruh test Bun, termasuk snapshot test.
  3. Gagalkan pipeline jika ada snapshot mismatch.
  4. Review perubahan snapshot di pull request.
  5. Update snapshot hanya saat perubahan kontrak memang disengaja.

Contoh perintah yang umum dipakai:

bun test

Jika tim Anda menggunakan tahap terpisah untuk kontrak API, tempatkan snapshot test pada job khusus agar kegagalannya mudah diidentifikasi. Yang penting, CI hanya memverifikasi, bukan memperbarui snapshot.

Tips integrasi CI

  • Gunakan data fixture yang stabil atau endpoint lokal yang deterministic.
  • Hindari ketergantungan ke layanan eksternal pada snapshot test utama.
  • Pastikan timezone, locale, dan seed data konsisten antar environment.
  • Jalankan test secara terisolasi jika ada state global.

Checklist mencegah flaky test pada snapshot API

  • Normalisasi UUID, timestamp, nonce, request ID, dan field dinamis lain.
  • Pastikan urutan array disortir jika API tidak menjamin ordering.
  • Jangan ambil data langsung dari sumber yang berubah-ubah.
  • Verifikasi status code dan header penting dengan assertion eksplisit.
  • Simpan snapshot sekecil mungkin, hanya bagian kontrak yang relevan.
  • Hindari snapshot untuk field yang memang volatil atau tidak penting bagi klien.
  • Jangan campur banyak endpoint berbeda ke satu snapshot besar.
  • Pastikan test tidak bergantung pada jam sistem, locale, atau timezone yang tidak dikontrol.
  • Jika ada paginasi, gunakan fixture yang konsisten agar jumlah item tidak berubah acak.
  • Review setiap perubahan snapshot seperti review perubahan API publik.

Debugging saat snapshot sering gagal

Snapshot berubah padahal perilaku tidak berubah

Biasanya penyebabnya adalah field dinamis yang lolos normalisasi, atau urutan object/array yang tidak stabil. Solusinya:

  • Log respons mentah dan respons setelah normalisasi.
  • Bandingkan field mana yang berubah di setiap run.
  • Tambahkan sorting atau placeholder hanya pada field penyebab noise.

Gagal hanya di CI

Ini sering terkait environment, misalnya timezone, locale, seed database, atau perbedaan header default. Pastikan test menjalankan input yang sama dan tidak mengandalkan perilaku host tertentu.

Snapshot terlalu sering di-update

Biasanya snapshot memuat terlalu banyak hal. Kurangi cakupan menjadi contract view yang memang ingin dijaga, lalu pindahkan pengecekan kritis ke assertion eksplisit.

Panduan adopsi bertahap pada codebase yang sudah berjalan

Anda tidak perlu langsung menambahkan snapshot ke semua endpoint. Pendekatan bertahap lebih aman dan lebih mudah diterima tim.

Langkah 1: pilih endpoint yang paling berisiko regresi

Mulai dari endpoint yang:

  • Dipakai frontend utama.
  • Menjadi dasar integrasi eksternal.
  • Sering berubah dan pernah menyebabkan bug kompatibilitas.
  • Punya payload JSON cukup kompleks.

Langkah 2: buat kontrak minimum dulu

Jangan snapshot seluruh respons besar dari awal. Kunci dulu:

  • Status code.
  • Header penting.
  • Bentuk JSON inti.
  • Field bisnis yang benar-benar dikonsumsi klien.

Langkah 3: tambahkan normalizer bersama utilitas test

Daripada tiap test punya logika sendiri, simpan helper seperti normalizeApiResponse, sorter array, dan pembentuk contract view di satu tempat. Ini memudahkan konsistensi review dan perawatan jangka panjang.

Langkah 4: tetapkan aturan review snapshot

Dokumentasikan bahwa perubahan snapshot harus dibaca, dijelaskan, dan dikaitkan dengan perubahan kontrak yang disengaja. Ini lebih penting daripada memilih gaya test tertentu.

Langkah 5: evaluasi endpoint yang tidak cocok untuk snapshot

Beberapa endpoint lebih sehat diuji dengan assertion eksplisit atau skema validasi. Tidak semua hal harus dijadikan snapshot. Fokus pada endpoint yang benar-benar mendapat manfaat dari diff kontrak yang mudah dibaca.

Penutup

Kontrak Snapshot API di Bun efektif untuk mencegah regresi respons jika dipakai dengan batasan yang jelas. Snapshot sebaiknya mengunci bentuk kontrak, sementara status code, header penting, dan invariants bisnis tetap diverifikasi dengan assertion eksplisit.

Supaya test tidak rapuh, normalisasi field dinamis seperti timestamp, id, nonce, dan urutan data yang tidak dijamin. Lalu perlakukan perubahan snapshot sebagai perubahan kontrak API yang harus direview serius, bukan sekadar file test yang boleh diperbarui otomatis.

Untuk codebase yang sudah berjalan, mulai dari beberapa endpoint paling kritis, buat helper normalizer bersama, kecilkan cakupan snapshot, dan tambah secara bertahap. Dengan pola ini, snapshot testing di Bun bisa menjadi alat yang praktis untuk menjaga stabilitas respons API tanpa menambah flaky test yang tidak perlu.