Contract Testing — Testing

Apa itu Contract Testing? Contract testing memastikan bahwa dua sistem (misalnya frontend dan backend) sepakat tentang bentuk data yang dikirim dan diterima…

Contract testing memastikan dua sistem yang saling bicara, misalnya frontend dan backend, tetap sepakat soal bentuk data yang dikirim. Kesepakatan itu disebut kontrak. Bayangin pesan katering untuk acara. Kamu menulis daftar pesanan: nasi kotak, lauk ayam, ada sambal. Dapur boleh menambah kerupuk tanpa bilang-bilang, tapi kalau ayamnya diganti ikan, acaramu kacau. Daftar pesanan itulah kontraknya, dan sebelum mengirim, dapur mencocokkan masakannya dengan daftar itu.

🧪 Tebakan cepat: backend menambah field baru phone ke response GET /api/users/1, dan frontend gak memakainya. Verifikasi kontraknya lulus atau gagal? Tebak dulu, jawabannya ada di bagian "Provider memverifikasi kontraknya".

Kenapa perlu kontrak

Dua aplikasi, dua repo, dua tim:

Suatu hari tim backend mengganti nama field name jadi fullName. Unit test backend hijau, karena mereka mengubah test-nya sekalian. Unit test frontend juga hijau, karena di sana response API-nya di-mock dan mock-nya masih berisi name. Lalu keduanya deploy, dan halaman produksi menampilkan "Halo, undefined". Test integrasi atau E2E bisa menangkapnya, tapi butuh kedua sistem jalan bersama, jadi lambat dan mahal. Contract test menangkapnya di CI masing-masing repo, tanpa harus menjalankan keduanya sekaligus.

Consumer menulis kontraknya

Pendekatan yang paling umum disebut consumer-driven contract: consumer yang menulis apa yang dia butuhkan, lalu provider membuktikan dia memenuhinya. Alat yang paling populer untuk ini Pact.

// web-toko: src/api.js
export async function ambilUser(baseUrl, id) {
  const res = await fetch(`${baseUrl}/api/users/${id}`);
  return res.json();
}
// web-toko: src/api.pact.test.js
import { PactV3, MatchersV3 } from "@pact-foundation/pact";
import { it, expect } from "vitest";
import { ambilUser } from "./api.js";

const { like } = MatchersV3;
const provider = new PactV3({ consumer: "web-toko", provider: "api-user" });

it("mengambil user 1", () => {
  provider
    .given("user 1 ada")
    .uponReceiving("permintaan user 1")
    .withRequest({ method: "GET", path: "/api/users/1" })
    .willRespondWith({
      status: 200,
      body: like({ id: 1, name: "Budi", email: "[email protected]" }),
    });

  return provider.executeTest(async (mockServer) => {
    const user = await ambilUser(mockServer.url, 1);
    expect(user.name).toBe("Budi");
  });
});

Provider memverifikasi kontraknya

Di repo api-user, Pact memutar ulang request dari file kontrak ke provider yang asli, lalu mencocokkan response-nya.

// api-user: test/pact.verify.test.js
import { Verifier } from "@pact-foundation/pact";
import { it } from "vitest";

it("memenuhi kontrak web-toko", () => {
  return new Verifier({
    provider: "api-user",
    providerBaseUrl: "http://localhost:3000",
    pactUrls: ["./pacts/web-toko-api-user.json"],
    stateHandlers: {
      "user 1 ada": async () => {
        // isi database test dengan user 1
      },
    },
  }).verifyProvider();
});
Response provider Hasil verifikasi
{ id: 1, name: "Budi", email: "[email protected]" } lulus
sama, ditambah phone: "081234567890" lulus
name diganti fullName gagal: field name hilang
id jadi string "1" gagal: tipenya bukan angka lagi

Field tambahan phone gak merusak apa pun: Pact cuma mengecek field yang ditulis consumer di kontrak, dan field lain diabaikan. Jadi verifikasinya lulus. Itu jawaban tebakannya. Yang merusak kontrak adalah menghapus atau mengganti nama field yang dibutuhkan, atau mengubah tipenya. Ini output Pact saat name diganti fullName:

Verifying a pact between web-toko and api-user

  permintaan user 1 (0s loading, 12ms verification)
     Given user 1 ada
    returns a response which
      has status code 200 (OK)
      has a matching body (FAILED)

Failures:

1) Verifying a pact between web-toko and api-user Given user 1 ada - permintaan user 1
    1.1) has a matching body
           $ -> Actual map is missing the following keys: name

There were 1 pact failures

$ adalah body-nya sendiri. Kalau id berubah jadi string, baris terakhirnya $.id -> Expected '1' (String) to be the same type as 1 (Integer). Verifikasi yang gagal bikin CI provider merah sebelum perubahannya sampai ke produksi.

Kebutuhan baru juga lewat jalur yang sama. Kalau frontend mulai butuh avatarUrl, consumer menambahkannya ke kontrak dulu. Verifikasi di provider gagal sampai backend menyediakan field itu, dan perintah pact-broker can-i-deploy dari Pact Broker menahan frontend supaya gak deploy duluan.

Cara yang lebih ringan: validasi schema

Kalau belum siap memakai Pact, kamu bisa mengecek bentuk response dengan library schema seperti Zod:

import { z } from "zod";

const UserSchema = z.object({
  id: z.number(),
  name: z.string(),
  email: z.string(),
});

const data = await ambilUser("http://localhost:3000", 1);
const parsed = UserSchema.parse(data); // melempar ZodError kalau bentuknya gak cocok

⚠️ Jebakan yang sering ketemu

TL;DR: Contract testing memastikan consumer (frontend) dan provider (backend) sepakat soal bentuk data. Di consumer-driven contract, consumer menulis kontraknya: dengan Pact, test consumer berjalan ke mock server dan menghasilkan file pact, lalu provider memutar ulang request-nya ke API asli lewat Verifier. Pakai like supaya yang dicek tipenya, bukan nilainya. Field tambahan di provider aman, tapi menghapus, mengganti nama, atau mengubah tipe field yang dibutuhkan bikin verifikasi gagal di CI. Versi yang lebih ringan: validasi response dengan Zod, UserSchema.parse(data), yang melempar error kalau bentuknya gak cocok.

Yang akan kamu pelajari