Tampilan
Kenapa penting
Sampai bab sebelumnya kamu sudah punya gateway, service yang saling memanggil, resilience, security, dan tracing. Masalah berikutnya bukan teknis murni, melainkan koordinasi antar tim: order-service dipegang tim A, payment-service dipegang tim B, dan keduanya deploy sendiri-sendiri. Suatu hari tim B "merapikan" response API, semua test mereka hijau, deploy berjalan mulus — lalu checkout di production gagal karena order-service masih membaca field lama. Tracing akan membantumu menemukan penyebabnya setelah insiden, tapi contract testing mencegah insiden itu sejak di pipeline CI. Di perusahaan yang menjalankan puluhan service, contract test adalah cara utama menjaga kebebasan deploy tanpa harus menjalankan seluruh sistem setiap kali ada perubahan kecil.
Konsep inti
Celah antara test unit dan production
Setiap tim menguji kodenya dengan mock. order-service memakai mock PaymentClient yang mengembalikan {"status": "PAID"}; payment-service menguji controller-nya yang kini mengembalikan {"state": "PAID"}. Dua-duanya hijau, padahal keduanya tidak lagi cocok. Mock itu adalah asumsi yang tidak pernah dicek ke pihak lain.
Pilihan klasik untuk menutup celah ini adalah test E2E di staging: nyalakan semua service lalu jalankan skenario checkout. Cara ini menangkap masalah, tapi lambat (menit sampai jam), rapuh (satu service down membuat semua test merah), dan sulit didiagnosis. Contract testing menawarkan jalan tengah: verifikasi kecocokan antarmuka tiap pasangan service secara terisolasi.
Consumer-driven contract
Kontrak adalah kesepakatan tertulis tentang satu interaksi: "bila consumer mengirim request ini, provider membalas response berbentuk ini". Pada pendekatan consumer-driven, consumer yang menuliskan apa yang benar-benar ia pakai, dan provider wajib lulus terhadap semua kontrak consumer-nya.
Program berikut menunjukkan ide intinya dengan Java murni — consumer hanya memeriksa field yang dipakainya:
java
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
public class ContractCheck {
// Kontrak versi consumer: field apa saja yang DIPAKAI order-service, beserta tipenya
static final Map<String, Class<?>> ORDER_SERVICE_EXPECTS = Map.of(
"paymentId", String.class,
"status", String.class,
"amount", Long.class);
static List<String> verify(Map<String, Object> providerResponse) {
List<String> violations = new ArrayList<>();
for (var entry : ORDER_SERVICE_EXPECTS.entrySet()) {
Object value = providerResponse.get(entry.getKey());
if (value == null) {
violations.add("field hilang: " + entry.getKey());
} else if (!entry.getValue().isInstance(value)) {
violations.add("tipe berubah: " + entry.getKey() + " sekarang "
+ value.getClass().getSimpleName());
}
}
return violations.stream().sorted().toList();
}
public static void main(String[] args) {
Map<String, Object> v1 = Map.of("paymentId", "PAY-1", "status", "PAID",
"amount", 150_000L, "method", "VA");
Map<String, Object> v2 = Map.of("paymentId", "PAY-1", "state", "PAID",
"amount", "150000.00");
System.out.println("v1: " + verify(v1));
System.out.println("v2: " + verify(v2));
}
}text
v1: []
v2: [field hilang: status, tipe berubah: amount sekarang String]Perhatikan dua hal. Field tambahan method di v1 tidak melanggar apa pun — menambah field itu kompatibel. Sebaliknya, mengganti nama field atau tipenya adalah breaking change. Alat contract testing mengotomasi pengecekan seperti ini, lengkap dengan request, header, dan status code.
Spring Cloud Contract: kontrak di sisi provider
Di Spring Cloud Contract, kontrak disimpan di repo provider (biasanya src/test/resources/contracts/), meski isinya diusulkan consumer lewat pull request. Kontrak bisa ditulis dengan Groovy DSL, Kotlin DSL, atau YAML:
yaml
# payment-service/src/test/resources/contracts/order/shouldCreatePayment.yml
description: order-service membuat pembayaran untuk pesanan
request:
method: POST
url: /payments
headers:
Content-Type: application/json
body:
orderId: "ORD-1001"
amount: 150000
response:
status: 201
headers:
Content-Type: application/json
body:
paymentId: "PAY-1"
status: "PENDING"
amount: 150000
matchers:
body:
- path: $.paymentId
type: by_regex
value: "PAY-[0-9]+"Plugin Maven membaca kontrak lalu membangkitkan test yang memanggil controller nyata. Kamu hanya menyediakan base class yang menyiapkan konteksnya:
xml
<!-- Potongan pom.xml payment-service -->
<plugin>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-contract-maven-plugin</artifactId>
<extensions>true</extensions>
<configuration>
<testFramework>JUNIT5</testFramework>
<baseClassForTests>id.jalurjava.payment.ContractBase</baseClassForTests>
</configuration>
</plugin>java
// Potongan — base class untuk test hasil generate
@SpringBootTest
@AutoConfigureMockMvc
public abstract class ContractBase {
@Autowired
MockMvc mockMvc;
@MockitoBean
PaymentGateway paymentGateway; // dependency eksternal di-mock, controller & service asli
@BeforeEach
void setUp() {
RestAssuredMockMvc.mockMvc(mockMvc);
when(paymentGateway.charge(any())).thenReturn(new ChargeResult("PAY-1"));
}
}Bila test hasil generate lulus, plugin juga mengemas stub WireMock ke artefak payment-service-<versi>-stubs.jar.
Stub Runner: kontrak di sisi consumer
order-service tidak lagi menulis mock sendiri. Ia memakai stub yang lahir dari kontrak yang sudah terverifikasi:
java
// Potongan — test di order-service
@SpringBootTest(properties = "payment.base-url=http://localhost:8090")
@AutoConfigureStubRunner(
ids = "id.jalurjava:payment-service:+:stubs:8090",
stubsMode = StubRunnerProperties.StubsMode.LOCAL)
class PaymentClientContractTest {
@Autowired
PaymentClient paymentClient;
@Test
void createsPaymentAccordingToContract() {
PaymentResponse response = paymentClient.create(new PaymentRequest("ORD-1001", 150_000L));
assertThat(response.status()).isEqualTo("PENDING");
assertThat(response.paymentId()).startsWith("PAY-");
}
}stubsMode = LOCAL mengambil stub dari repository Maven lokal (~/.m2); di CI biasanya REMOTE dengan repositoryRoot mengarah ke repository artefak tim. Karena stub dan test provider berasal dari file kontrak yang sama, mock consumer tidak mungkin menyimpang dari perilaku provider tanpa ketahuan.
Pact: alternatif lintas bahasa
Pact membalik alurnya: consumer menulis test terhadap mock server Pact, dan test itu menghasilkan file kontrak JSON (pact file). File diunggah ke Pact Broker, lalu build provider mengunduh dan memverifikasinya. Pact unggul bila service ditulis dalam banyak bahasa (Java, Node, Go, Python) dan menyediakan perintah can-i-deploy untuk mengecek apakah versi tertentu aman dirilis bersama versi lain di production.
| Aspek | Spring Cloud Contract | Pact |
|---|---|---|
| Lokasi kontrak | repo provider (diusulkan consumer) | dihasilkan test consumer, disimpan di Broker |
| Bahasa | terutama JVM/Spring | banyak bahasa |
| Artefak consumer | stub JAR (WireMock) | mock server Pact |
| Cocok untuk | organisasi yang seragam Spring | lingkungan poliglot |
Kontrak event Kafka
Integrasi asinkron juga butuh kontrak. Bila payment-service menerbitkan event PaymentCompleted, consumer (order-service, notification-service) bergantung pada bentuk payload-nya. Spring Cloud Contract mendukung kontrak messaging:
yaml
# payment-service/src/test/resources/contracts/events/paymentCompleted.yml
label: payment_completed
input:
triggeredBy: completePayment()
outputMessage:
sentTo: payment.completed
headers:
contentType: application/json
body:
paymentId: "PAY-1"
orderId: "ORD-1001"
status: "PAID"Di sisi provider, base class menyediakan method completePayment() yang memicu publikasi event; test hasil generate memeriksa pesan yang terkirim. Di sisi consumer, StubTrigger bisa memicu label payment_completed agar listener diuji dengan payload yang sudah disepakati. Alternatif lain yang umum adalah schema registry (Avro/Protobuf) dengan aturan kompatibilitas — keduanya bisa saling melengkapi.
Contract test di CI
Alur yang lazim:
- Consumer mengusulkan kontrak baru lewat pull request ke repo provider.
- Build provider menjalankan test hasil generate; bila lulus, stub JAR dipublikasikan bersama versi provider.
- Build consumer menjalankan test dengan Stub Runner terhadap versi stub yang sedang berjalan di production (bukan sekadar versi terbaru), agar rilis consumer tidak bergantung pada fitur provider yang belum dideploy.
- Perubahan breaking dilakukan bertahap: tambah field baru, migrasikan semua consumer, baru hapus field lama.
Strategi test microservices
| Lapisan | Menguji | Kecepatan | Porsi |
|---|---|---|---|
| Unit | business logic satu class | milidetik | paling banyak |
| Komponen | satu service utuh + Testcontainers, dependency lain di-stub | detik | banyak |
| Kontrak | kecocokan antarmuka tiap pasangan consumer–provider | detik | tiap integrasi |
| E2E | alur bisnis kritis lintas semua service | menit | sedikit (mis. checkout, refund) |
Contract test tidak menggantikan test lain. Ia menjawab satu pertanyaan sempit tapi krusial: "apakah kedua pihak masih bicara bahasa yang sama?"
Contoh di dunia backend
Tim pesanan butuh field baru failureReason saat pembayaran gagal. Alurnya dengan consumer-driven contract:
groovy
// payment-service/src/test/resources/contracts/order/shouldRejectPaymentOverLimit.groovy
// Diusulkan tim pesanan lewat pull request ke repo payment-service
import org.springframework.cloud.contract.spec.Contract
Contract.make {
description "pembayaran di atas limit ditolak dengan alasan yang bisa dibaca order-service"
request {
method POST()
url "/payments"
headers { contentType applicationJson() }
body(orderId: "ORD-2002", amount: 99_000_000)
}
response {
status UNPROCESSABLE_ENTITY()
headers { contentType applicationJson() }
body(status: "REJECTED", failureReason: "LIMIT_EXCEEDED")
}
}Build payment-service langsung merah sampai tim pembayaran mengimplementasikan validasi limit dan field failureReason. Setelah hijau, stub baru terbit, dan order-service menulis test penanganan penolakan:
java
// Potongan — order-service memakai stub terbaru
@Test
void marksOrderAsPaymentFailedWhenLimitExceeded() {
Order order = orderService.checkout(new CheckoutCommand("ORD-2002", 99_000_000L));
assertThat(order.status()).isEqualTo(OrderStatus.PAYMENT_FAILED);
assertThat(order.failureReason()).isEqualTo("LIMIT_EXCEEDED");
}Tidak ada staging yang perlu dinyalakan, dan kedua tim tahu persis kapan integrasinya siap.
Pertanyaan interview
1. Apa beda contract test dengan integration test atau E2E test?
Integration/E2E test menjalankan beberapa komponen nyata bersama dan menguji perilaku keseluruhan. Contract test menguji tiap sisi secara terpisah terhadap satu dokumen kesepakatan: provider diverifikasi bahwa ia memenuhi kontrak, consumer diuji terhadap stub yang lahir dari kontrak yang sama. Hasilnya cepat, deterministik, dan langsung menunjuk pasangan service mana yang tidak cocok.
2. Kenapa disebut consumer-driven, dan apa untungnya?
Karena consumer yang menyatakan bagian API yang ia pakai. Provider jadi tahu persis field mana yang aman diubah — field yang tidak muncul di kontrak consumer mana pun bisa direfaktor dengan bebas. Ini menghindari dua ekstrem: provider takut mengubah apa pun, atau provider mengubah seenaknya lalu merusak consumer.
3. Bagaimana mengubah API secara breaking tanpa merusak consumer?
Lakukan expand–contract: tambahkan bentuk baru di samping yang lama (field baru, endpoint v2), rilis provider, migrasikan consumer satu per satu sambil memperbarui kontraknya, lalu hapus bentuk lama setelah tidak ada kontrak yang memakainya. Contract test memberi bukti kapan langkah terakhir aman dilakukan.
Jebakan umum
- Menulis kontrak dari sudut pandang provider ("semua field yang kami kirim") — kontrak jadi rapuh dan setiap penambahan field memaksa perubahan kontrak yang tidak perlu.
- Mencocokkan nilai persis untuk data dinamis seperti ID, timestamp, atau UUID — pakai matcher regex/tipe agar kontrak tidak gagal karena nilai acak.
- Memasukkan business logic ke kontrak (mis. menguji perhitungan diskon lengkap) — itu tugas test unit; kontrak cukup memastikan bentuk interaksi.
- Consumer selalu memakai stub versi
+(terbaru) di CI tanpa memastikan versi provider itu sudah ada di production. - Lupa kontrak untuk event Kafka — integrasi asinkron sama rawannya, hanya error-nya muncul lebih lambat.
- Mematikan contract test yang gagal demi pipeline hijau, padahal kegagalannya adalah peringatan breaking change.
Ringkasan
- Test unit tiap tim hanya memvalidasi asumsi tim itu sendiri; contract test memverifikasi kecocokan antar service tanpa menyalakan seluruh sistem.
- Consumer-driven contract: consumer menyatakan bagian API yang dipakai, provider wajib lulus terhadap semua kontrak consumer-nya.
- Spring Cloud Contract membangkitkan test provider dari kontrak dan menerbitkan stub JAR yang dipakai consumer lewat
@AutoConfigureStubRunner; Pact unggul di lingkungan poliglot dengan Pact Broker. - Kontrak juga berlaku untuk event Kafka — gunakan kontrak messaging atau schema registry.
- Strategi seimbang: banyak test unit dan komponen, contract test untuk tiap integrasi, dan sedikit E2E untuk alur kritis.