Contract test di Rust berguna untuk memastikan bentuk request dan response antar layanan tetap kompatibel saat kode berubah. Jika unit test terlalu lokal dan end-to-end test terlalu mahal atau flaky, contract test memberi titik tengah yang praktis: cukup dekat dengan integrasi nyata, tetapi tetap deterministik dan cepat dijalankan di CI.
Untuk mencegah regresi integrasi API, contract test sebaiknya memverifikasi hal yang benar-benar dipakai consumer: endpoint, method, status code, header penting, tipe field, field wajib, dan beberapa aturan semantik yang kritikal. Artikel ini fokus pada strategi test, workflow verifikasi, serta contoh implementasi Rust memakai reqwest, serde, dan test harness bawaan.
Kapan contract test lebih tepat dibanding unit test atau end-to-end test
Unit test: cepat, tetapi tidak cukup untuk kontrak HTTP
Unit test bagus untuk memverifikasi logika parsing, validasi, atau mapping data secara terisolasi. Masalahnya, unit test tidak cukup untuk menangkap regresi seperti:
- path endpoint berubah,
- nama field JSON berubah,
- status code berubah dari 200 menjadi 204,
- field yang tadinya wajib menjadi opsional atau sebaliknya,
- header autentikasi atau content-type tidak sesuai.
Karena itu, unit test tidak bisa menjadi satu-satunya perlindungan untuk integrasi API antar layanan.
End-to-end test: cakupan luas, tetapi mahal dan sering tidak stabil
End-to-end test memverifikasi alur penuh lintas layanan, database, queue, dan dependency eksternal. Ini penting, tetapi sering menimbulkan masalah:
- setup environment kompleks,
- waktu eksekusi lama,
- sulit menentukan akar masalah saat gagal,
- rentan flaky karena jaringan, data bersama, atau service dependency.
End-to-end test cocok untuk beberapa skenario bisnis kritikal, bukan untuk memverifikasi seluruh detail kontrak API di setiap perubahan kecil.
Contract test: titik tengah yang paling praktis untuk regresi integrasi
Contract test paling tepat ketika Anda ingin memastikan producer dan consumer tetap kompatibel tanpa harus menyalakan seluruh sistem. Fokusnya adalah kontrak antar layanan, bukan seluruh alur bisnis internal.
Gunakan contract test ketika:
- ada banyak consumer terhadap satu API,
- perubahan payload sering terjadi,
- tim producer dan consumer bekerja terpisah,
- end-to-end test terlalu lambat untuk dijadikan pagar utama di CI,
- regresi yang paling sering muncul adalah mismatch schema atau field response.
Aturan praktis: unit test melindungi logika lokal, contract test melindungi batas integrasi, end-to-end test melindungi alur bisnis lintas sistem.
Apa yang perlu diverifikasi dalam contract test API
Kesalahan umum adalah membuat contract test terlalu dangkal, misalnya hanya memeriksa bahwa respons berisi JSON. Agar efektif mencegah regresi, verifikasi kontrak harus spesifik pada kebutuhan consumer.
Elemen kontrak yang sebaiknya diuji
- HTTP method dan path: misalnya GET /users/{id}.
- Status code: 200, 404, 401, dan sebagainya.
- Header penting: Content-Type, header versi, atau header autentikasi tertentu.
- Field wajib: field yang pasti dipakai consumer dan tidak boleh hilang.
- Tipe data field: string, integer, boolean, array, object.
- Makna field kritikal: misalnya status hanya boleh bernilai tertentu, atau id tidak boleh kosong.
- Perilaku kompatibilitas: penambahan field baru umumnya aman, perubahan nama field biasanya memutus consumer.
Bedakan field penting dan field tambahan
Consumer tidak harus mengunci seluruh payload jika hanya memakai sebagian field. Jika test memverifikasi semua field secara kaku, perubahan non-breaking bisa terlihat seperti breaking change. Strategi yang lebih aman adalah:
- verifikasi field yang benar-benar dipakai consumer,
- izinkan field tambahan selama tidak mengubah field wajib,
- uji beberapa invariants penting, bukan semua detail presentasi.
Dengan pendekatan ini, contract test tetap ketat pada area yang penting tanpa menghambat evolusi API.
Sumber flaky test pada integrasi HTTP dan cara menstabilkannya
Penyebab flaky test yang paling umum
- Ketergantungan jaringan nyata: DNS, timeout, latensi, atau service sementara tidak tersedia.
- State bersama: test saling mengganggu karena memakai resource yang sama.
- Data tidak deterministik: timestamp, UUID acak, urutan array yang tidak stabil.
- Ketergantungan waktu: token kedaluwarsa, jam sistem berbeda, window waktu sempit.
- Parallel execution: test bawaan Rust berjalan paralel dan bisa berbenturan jika state global tidak diisolasi.
Prinsip stabilisasi test
- Gunakan mock server lokal untuk mengganti dependency HTTP eksternal.
- Fixture deterministik untuk payload request/response.
- Isolasi state per test agar tidak ada kebocoran data antar test.
- Hindari assert yang sensitif terhadap detail tak penting, seperti urutan field JSON jika memang tidak dijamin.
- Jangan bergantung pada waktu nyata; injeksikan nilai waktu atau gunakan nilai tetap di fixture.
Fixture deterministik
Fixture sebaiknya disimpan sebagai file JSON terpisah agar mudah direview saat kontrak berubah. Contoh struktur sederhana:
tests/
contract/
consumer_get_user.rs
fixtures/
get_user_200.json
get_user_404.jsonKeuntungannya:
- payload bisa ditinjau lewat pull request,
- perubahan kontrak terlihat jelas di diff,
- fixture dapat dipakai ulang untuk beberapa test.
Isolasi state
Jika test mengandalkan state global, hasilnya bisa tidak konsisten ketika dijalankan paralel. Minimal lakukan hal berikut:
- buat mock server baru per test,
- jangan gunakan port tetap jika tidak perlu,
- simpan file sementara di direktori unik per test,
- hindari cache global yang tidak di-reset.
Bila ada test yang memang tidak aman untuk paralel, lebih baik refactor dulu. Menonaktifkan paralel secara global hanya menyembunyikan masalah dan memperlambat suite test.
Implementasi contract test di Rust dengan reqwest, serde, dan test harness bawaan
Contoh berikut menunjukkan consumer yang memanggil API pengguna. Kita akan menguji bahwa consumer tetap bisa membaca respons yang sesuai kontrak, dan gagal secara jelas ketika kontrak berubah.
Model response di sisi consumer
use serde::Deserialize;
#[derive(Debug, Deserialize)]
struct UserResponse {
id: String,
email: String,
active: bool,
}
Model ini sengaja hanya memuat field yang benar-benar dibutuhkan consumer. Ini penting agar consumer tidak terlalu kaku terhadap field tambahan yang tidak relevan.
Client API sederhana
use reqwest::StatusCode;
use serde::Deserialize;
#[derive(Debug, Deserialize)]
struct UserResponse {
id: String,
email: String,
active: bool,
}
#[derive(Clone)]
struct UserApiClient {
base_url: String,
http: reqwest::Client,
}
impl UserApiClient {
fn new(base_url: impl Into<String>) -> Self {
Self {
base_url: base_url.into(),
http: reqwest::Client::new(),
}
}
async fn get_user(&self, user_id: &str) -> Result<UserResponse, String> {
let url = format!("{}/users/{}", self.base_url, user_id);
let resp = self.http
.get(url)
.send()
.await
.map_err(|e| format!("request gagal: {e}"))?;
if resp.status() != StatusCode::OK {
return Err(format!("status tidak sesuai: {}", resp.status()));
}
let content_type = resp
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or("");
if !content_type.contains("application/json") {
return Err(format!("content-type tidak valid: {content_type}"));
}
resp.json::<UserResponse>()
.await
.map_err(|e| format!("parse response gagal: {e}"))
}
}
Verifikasi di atas sederhana tetapi penting: status code, content-type, lalu parsing payload ke model consumer. Dengan begitu, perubahan pada producer yang merusak consumer akan muncul sebagai kegagalan test.
Contract test dengan mock server
Ekosistem Rust punya beberapa pilihan mock HTTP. Nama crate bisa berbeda sesuai preferensi tim, tetapi polanya sama: jalankan server lokal sementara, definisikan request yang diharapkan, lalu kembalikan fixture deterministik. Berikut contoh memakai pola mock server umum:
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
#[tokio::test]
async fn get_user_memenuhi_contract_response_200() {
let body = fs::read_to_string("tests/contract/fixtures/get_user_200.json")
.expect("fixture harus bisa dibaca");
let server = MockServer::start();
server.mock(|when, then| {
when.method("GET")
.path("/users/u-123");
then.status(200)
.header("content-type", "application/json")
.body(body.clone());
});
let client = UserApiClient::new(server.base_url());
let user = client.get_user("u-123").await.expect("response harus valid");
assert_eq!(user.id, "u-123");
assert_eq!(user.email, "alice@example.com");
assert!(user.active);
}
#[tokio::test]
async fn get_user_gagal_jika_field_penting_hilang() {
let server = MockServer::start();
server.mock(|when, then| {
when.method("GET")
.path("/users/u-123");
then.status(200)
.header("content-type", "application/json")
.body(r#"{
"id": "u-123",
"active": true
}"#);
});
let client = UserApiClient::new(server.base_url());
let err = client.get_user("u-123").await.unwrap_err();
assert!(err.contains("parse response gagal"));
}
}
Contoh ini memperlihatkan dua hal penting:
- consumer hanya menerima respons yang memenuhi kontrak minimalnya,
- hilangnya field wajib seperti email langsung terdeteksi.
Anda tidak harus mengunci seluruh body JSON. Cukup verifikasi bagian yang memang mempengaruhi perilaku consumer.
Validasi field penting dan toleransi terhadap field tambahan
Secara default, serde akan mengabaikan field tambahan yang tidak dideklarasikan pada struct. Untuk contract test consumer, ini biasanya menguntungkan karena penambahan field baru oleh producer tidak otomatis dianggap breaking change.
Namun, jika Anda ingin lebih ketat pada sisi tertentu, tambahkan validasi lanjutan setelah deserialisasi:
impl UserResponse {
fn validate(&self) -> Result<(), String> {
if self.id.trim().is_empty() {
return Err("id tidak boleh kosong".into());
}
if !self.email.contains('@') {
return Err("email tidak valid".into());
}
Ok(())
}
}
Pola ini berguna untuk memisahkan:
- schema compatibility: payload bisa di-parse,
- semantic validity: nilai field masih masuk akal untuk consumer.
Strategi verification workflow di CI
Struktur folder test yang rapi
Susunan file yang jelas membantu review dan menjaga test tetap mudah dirawat:
src/
client.rs
models.rs
tests/
contract/
consumer_get_user.rs
consumer_list_users.rs
fixtures/
get_user_200.json
get_user_404.json
list_users_200.json
Pisahkan contract test dari unit test agar tujuan masing-masing jelas. Jika tim memiliki banyak domain API, Anda juga bisa mengelompokkan per resource atau per consumer.
Alur CI yang disarankan
- Jalankan unit test lebih dulu untuk umpan balik cepat.
- Jalankan contract test consumer yang memakai mock server dan fixture lokal.
- Jika ada producer dalam repo yang sama atau environment verifikasi terpisah, jalankan verifikasi producer terhadap kontrak yang disepakati.
- Gagalkan pipeline jika ada kontrak yang tidak kompatibel.
Untuk penggunaan sehari-hari, contract test sebaiknya cukup cepat untuk dieksekusi di setiap pull request. Jangan menambahkan dependency eksternal yang membuat tahap ini lambat atau tidak stabil.
Contoh perintah CI yang sederhana
cargo test
cargo test --test consumer_get_user
Jika test integrasi dikelompokkan dalam direktori tests, Anda dapat memilih subset test tertentu untuk pipeline yang lebih spesifik. Intinya bukan nama perintahnya, melainkan pemisahan tahap agar kegagalan mudah dilokalisasi.
Versioning kontrak dan cara mengelola perubahan response
Perubahan yang umumnya aman
- menambah field baru yang tidak diwajibkan consumer,
- menambah endpoint baru tanpa mengubah yang lama,
- memperluas enum atau status jika consumer memang toleran terhadap nilai baru.
Perubahan yang berisiko merusak consumer
- menghapus field yang dipakai consumer,
- mengganti nama field,
- mengubah tipe field, misalnya integer menjadi string,
- mengubah makna field tanpa mengganti nama,
- mengubah status code default untuk skenario yang sama.
Pendekatan versioning yang realistis
Tidak semua perubahan perlu membuat versi API baru. Namun, jika perubahan benar-benar breaking untuk consumer yang ada, jangan berharap contract test menyelesaikannya sendirian. Anda tetap perlu strategi versioning, misalnya:
- pertahankan endpoint lama selama masa transisi,
- gunakan header atau path versioning sesuai kebijakan tim,
- publikasikan fixture atau schema kontrak versi lama dan baru selama migrasi,
- pastikan CI memverifikasi kompatibilitas terhadap consumer yang masih aktif.
Prinsip utamanya: contract test mendeteksi ketidakcocokan, sedangkan versioning mengelola perubahan yang memang tidak bisa kompatibel.
Checklist review agar perubahan respons tidak merusak consumer
Sebelum merge perubahan pada API producer atau model consumer, gunakan checklist berikut:
- Apakah ada field yang dihapus, diganti nama, atau berubah tipe?
- Apakah status code untuk skenario sukses atau error berubah?
- Apakah consumer hanya memverifikasi field penting, bukan seluruh payload secara berlebihan?
- Apakah fixture response sudah diperbarui dan direview?
- Apakah ada nilai default baru yang mengubah perilaku consumer?
- Apakah field baru aman diabaikan oleh consumer lama?
- Apakah test tetap deterministik tanpa bergantung pada waktu, jaringan nyata, atau state bersama?
- Apakah perubahan ini butuh versi kontrak baru atau masa transisi?
Kesalahan umum saat menerapkan contract test di Rust
Menguji terlalu banyak detail yang tidak penting
Jika consumer hanya membutuhkan tiga field, jangan paksa seluruh JSON identik dengan fixture produksi. Test seperti ini mahal dirawat dan sering gagal karena perubahan non-breaking.
Tidak memisahkan kontrak dari perilaku bisnis internal
Contract test seharusnya fokus pada batas integrasi. Jangan memasukkan seluruh logika domain producer ke dalam test consumer, karena hasilnya rapuh dan sulit dipahami.
Mock terlalu jauh dari kenyataan
Mock server membantu stabilitas, tetapi fixture harus realistis. Jika fixture terlalu sederhana dan tidak mewakili payload sebenarnya, Anda bisa lolos di test tetapi gagal di produksi. Ambil sampel payload nyata yang sudah disanitasi, lalu jadikan fixture dasar.
Mencampur contract test dengan dependence eksternal yang tidak perlu
Jika contract test masih memanggil service lain, database bersama, atau environment staging penuh, test akan kehilangan manfaat utamanya: cepat, terisolasi, dan deterministik.
Penutup
Strategi contract test di Rust untuk cegah regresi integrasi API paling efektif jika difokuskan pada kebutuhan nyata consumer: method, path, status code, header penting, field wajib, tipe data, dan validasi semantik yang kritikal. Dibanding end-to-end test, pendekatan ini lebih cepat dan lebih stabil; dibanding unit test, ia benar-benar melindungi batas integrasi.
Dengan mock server lokal, fixture deterministik, dan isolasi state, Anda bisa membangun verification workflow yang dapat dipercaya di CI. Hasil akhirnya bukan sekadar test yang hijau, tetapi perubahan API yang lebih aman untuk producer maupun consumer.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!