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
phoneke responseGET /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:
- Consumer adalah yang memakai data: di sini frontend
web-toko, yang menampilkan "Halo, Budi" dariuser.name. - Provider adalah yang menyediakan data: di sini backend
api-user, dengan endpointGET /api/users/1.
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");
});
});
given("user 1 ada")adalah provider state: keadaan yang harus disiapkan provider sebelum request ini, misalnya user 1 sudah ada di database.uponReceiving(...)memberi nama interaksinya, danwithRequest(...)adalah request yang dikirim consumer.willRespondWith(...)adalah response yang dibutuhkan consumer.like(...)artinya cocokkan tipenya, bukan nilainya:idharus angka,namedanemailharus string. Provider boleh mengembalikan "Siti", yang penting string.executeTestmenyalakan mock server Pact yang menjawab persis seperti kontrak, lalu menjalankan kode consumer yang asli ke sana. Kalau test-nya lulus, Pact menulis kontraknya ke filepacts/web-toko-api-user.json. File inilah yang dikirim ke provider, biasanya lewat Pact Broker.
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
UserSchema.parse(data)mengembalikan datanya kalau cocok, dan melempar error kalau gak.UserSchema.safeParse(data)gak melempar, tapi mengembalikan{ success, data }atau{ success, error }.- Bedanya dengan Pact: schema ini cuma dicek di satu sisi. Provider gak tahu ada consumer yang bergantung pada
name, jadi dia baru tahu setelah ada yang rusak.
⚠️ Jebakan yang sering ketemu
- Provider yang menulis kontraknya. Di consumer-driven contract, consumer yang menulis kebutuhannya, provider yang memverifikasi.
- Mencocokkan nilai persis. Tanpa
like, provider harus mengembalikan "Budi" persis. Data test sedikit berubah, verifikasinya gagal padahal bentuknya benar. Pakai matcher tipe. - Memasukkan semua field ke kontrak. Tulis cuma field yang benar-benar dipakai consumer, supaya provider bebas mengubah sisanya.
- Menganggap contract test mengecek logika. Kontrak mengecek bentuk data. Benar atau gaknya isi data, misalnya total harga, tetap tugas unit test provider.
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.