Contract test API membantu tim mendeteksi regresi integrasi antarservis lebih cepat dibanding menunggu end-to-end test gagal di lingkungan staging. Untuk backend Rust yang menyediakan atau mengonsumsi HTTP JSON, pendekatan ini berguna saat perubahan kecil seperti field baru, status code berbeda, atau format error yang bergeser dapat memutus integrasi meskipun unit test tetap hijau.

Intinya, contract test memverifikasi kesepakatan antara provider dan consumer API: bentuk request, struktur response, status code, header penting, dan perilaku error. Dengan begitu, perubahan antarmuka bisa ditangkap di CI sebelum dirilis, tanpa biaya dan ketidakstabilan yang biasanya muncul pada pengujian end-to-end penuh.

Kapan contract test lebih efektif daripada hanya unit test atau end-to-end test

Keterbatasan unit test

Unit test kuat untuk memeriksa logika lokal: validasi input, mapping struct, transformasi data, atau perilaku handler dalam isolasi. Namun, unit test biasanya tidak cukup untuk memastikan hal-hal berikut:

  • Nama field JSON tetap sama.
  • Field yang wajib tetap ada.
  • Status code HTTP tidak berubah tanpa sengaja.
  • Bentuk error response masih bisa dipahami consumer.
  • Provider dan consumer memiliki asumsi yang sama tentang tipe data.

Contoh umum: provider mengganti user_id menjadi id, atau mengubah status code dari 404 menjadi 200 dengan body kosong. Unit test internal provider bisa tetap lolos, tetapi consumer rusak.

Keterbatasan end-to-end test

End-to-end test penting karena menguji integrasi nyata antarkomponen. Masalahnya, test jenis ini sering:

  • Lambat dijalankan.
  • Bergantung pada banyak service sekaligus.
  • Sulit didiagnosis saat gagal.
  • Lebih mudah flaky karena data, jaringan, waktu, atau dependensi eksternal.

Contract test berada di tengah: cakupannya lebih luas daripada unit test, tetapi lebih terfokus dan deterministik daripada end-to-end test.

Kapan contract test paling tepat

Gunakan contract test saat:

  • Ada lebih dari satu service yang berkomunikasi via HTTP JSON.
  • Tim provider dan consumer berkembang terpisah.
  • Perubahan API sering terjadi.
  • Kegagalan integrasi biasanya baru terdeteksi di staging atau produksi.
  • Anda ingin validasi kompatibilitas di CI tanpa menghidupkan seluruh sistem.

Praktiknya: unit test memeriksa logika internal, contract test memeriksa antarmuka integrasi, dan end-to-end test memeriksa alur bisnis penting dari ujung ke ujung. Ketiganya saling melengkapi, bukan saling menggantikan.

Apa yang sebaiknya divalidasi dalam contract test API di Rust

Pada konteks HTTP JSON, kontrak sebaiknya mencakup elemen yang benar-benar diandalkan consumer.

Request yang dikirim consumer

  • Method HTTP: GET, POST, dan seterusnya.
  • Path dan parameter query.
  • Header penting seperti Authorization atau Content-Type.
  • Body JSON dan field wajib.

Response dari provider

  • Status code yang diharapkan.
  • Header penting bila memang digunakan consumer.
  • Struktur JSON: field wajib, tipe data, nested object, dan array.
  • Nilai tertentu yang punya makna kontraktual, misalnya enum status.

Error response

Bagian ini sering terlupakan, padahal regresi integrasi justru banyak terjadi di jalur error. Pastikan Anda memverifikasi:

  • Status code untuk skenario gagal, misalnya 400, 404, atau 409.
  • Struktur body error, misalnya code, message, dan details.
  • Perbedaan antara error validasi dan error domain.

Jika consumer bergantung pada code tertentu untuk branching logic, field itu harus menjadi bagian eksplisit dari kontrak.

Contoh arsitektur sederhana: provider dan consumer Rust berbasis HTTP JSON

Misalkan ada dua service:

  • user-service sebagai provider, menyediakan endpoint GET /users/{id}.
  • order-service sebagai consumer, memanggil user-service untuk mengambil data user sebelum memproses order.

Kontrak yang disepakati:

GET /users/123
HTTP/1.1 200 OK
Content-Type: application/json

{
  "id": "123",
  "name": "Nadia",
  "email": "[email protected]",
  "status": "active"
}

Dan untuk user yang tidak ada:

HTTP/1.1 404 Not Found
Content-Type: application/json

{
  "code": "USER_NOT_FOUND",
  "message": "user not found"
}

Di Rust, Anda bisa merepresentasikan bentuk data ini dengan struct yang jelas agar perubahan kontrak lebih terlihat.

use serde::{Deserialize, Serialize};

#[derive(Debug, Serialize, Deserialize)]
struct UserResponse {
    id: String,
    name: String,
    email: String,
    status: String,
}

#[derive(Debug, Serialize, Deserialize)]
struct ApiError {
    code: String,
    message: String,
}

Struct ini sendiri belum cukup menjadi contract test. Nilainya baru muncul ketika provider dan consumer sama-sama memverifikasi bahwa payload nyata sesuai kontrak yang disepakati.

Struktur contract test yang praktis di proyek Rust

Pola consumer-driven contract

Pola yang paling praktis untuk tim kecil biasanya consumer-driven contract. Consumer mendefinisikan ekspektasi minimum terhadap provider. Provider lalu memverifikasi bahwa implementasinya masih memenuhi ekspektasi itu.

Keuntungannya:

  • Kontrak berangkat dari kebutuhan nyata consumer.
  • Provider tidak perlu menebak field mana yang benar-benar dipakai.
  • Perubahan breaking bisa terdeteksi sebelum deployment.

Struktur direktori yang mudah dipelihara

Contoh struktur repository:

project/
  user-service/
    src/
    tests/
      contract_provider.rs
    contracts/
      user_get_by_id_v1.json
  order-service/
    src/
    tests/
      contract_consumer.rs
    contracts/
      user_get_by_id_v1.json
  fixtures/
    users/
      active_user.json
      user_not_found.json

Pemisahan ini membantu karena:

  • Fixture bisa dipakai ulang.
  • Kontrak terdokumentasi dalam bentuk file.
  • Perubahan kontrak bisa direview melalui pull request.

Contoh test consumer di Rust

Consumer dapat menjalankan test terhadap mock server agar request dan parsing response tervalidasi secara deterministik. Contoh berikut bersifat generik dan tidak bergantung pada framework tertentu.

#[tokio::test]
async fn get_user_returns_expected_shape() {
    // 1. Siapkan mock server yang mengembalikan kontrak yang disepakati.
    // 2. Panggil client HTTP dari order-service.
    // 3. Verifikasi request yang dikirim dan response yang diparsing.

    let body = r#"{
        "id": "123",
        "name": "Nadia",
        "email": "[email protected]",
        "status": "active"
    }"#;

    // pseudo:
    // let server = spawn_mock_server("GET", "/users/123", 200, body);
    // let client = UserClient::new(server.base_url());
    // let user = client.get_user("123").await.unwrap();

    let user: UserResponse = serde_json::from_str(body).unwrap();

    assert_eq!(user.id, "123");
    assert_eq!(user.status, "active");
}

Yang penting bukan library mock tertentu, melainkan prinsipnya:

  • Consumer memverifikasi request yang dia kirim memang sesuai kontrak.
  • Consumer memverifikasi bahwa response provider dalam bentuk yang disepakati bisa diproses tanpa kejutan.

Contoh verifikasi provider di Rust

Di sisi provider, test memanggil handler HTTP lokal atau menjalankan server test, lalu memastikan output aktual cocok dengan kontrak.

#[tokio::test]
async fn provider_returns_404_error_contract() {
    // pseudo:
    // let app = test_app_with_fixture_data();
    // let response = app.get("/users/999").await;

    let status = 404;
    let body = r#"{
        "code": "USER_NOT_FOUND",
        "message": "user not found"
    }"#;

    assert_eq!(status, 404);

    let err: ApiError = serde_json::from_str(body).unwrap();
    assert_eq!(err.code, "USER_NOT_FOUND");
}

Test provider tidak hanya memeriksa bahwa endpoint berjalan, tetapi bahwa responsenya tetap kompatibel dengan kontrak yang telah dipublikasikan.

Fixture data: kecil, stabil, dan eksplisit

Fixture adalah salah satu sumber kejelasan sekaligus sumber kekacauan jika tidak dikelola. Untuk contract test, fixture sebaiknya:

  • Kecil dan fokus pada satu skenario.
  • Tidak bergantung pada data produksi.
  • Tidak berubah karena waktu berjalan.
  • Mewakili skenario sukses dan gagal.

Contoh fixture yang baik

{
  "id": "123",
  "name": "Nadia",
  "email": "[email protected]",
  "status": "active"
}

Dan fixture error:

{
  "code": "USER_NOT_FOUND",
  "message": "user not found"
}

Hindari fixture yang membuat test tidak deterministik

  • Timestamp real-time yang berubah di setiap run.
  • ID acak yang tidak dikontrol.
  • Urutan array yang tidak stabil.
  • Ketergantungan pada database bersama.

Jika API memang mengandung field dinamis seperti created_at atau request_id, validasi hanya bagian yang kontraktual. Misalnya, cek bahwa field ada dan bertipe string, bukan memaksa nilainya identik setiap kali.

Sumber flaky test pada integrasi API dan cara menguranginya

Flaky test membuat tim kehilangan kepercayaan pada pipeline. Pada contract testing, flaky biasanya datang bukan dari konsep kontrak itu sendiri, melainkan dari cara pengujiannya disusun.

1. Port dan network dependency

Menjalankan server sungguhan pada port tetap bisa bentrok di CI atau saat test paralel. Gunakan port acak yang dialokasikan runtime, atau panggil handler secara in-process bila memungkinkan.

2. Waktu dan timezone

Field tanggal yang dihasilkan berdasarkan waktu sekarang sering memicu mismatch. Gunakan clock yang bisa diinjeksi agar test memakai waktu tetap.

3. Shared state

Jika beberapa test memakai database atau cache yang sama, hasilnya bisa tergantung urutan eksekusi. Lebih aman memakai data setup per test atau reset state di awal.

4. Ketergantungan ke service eksternal

Contract test sebaiknya tidak memanggil service nyata di jaringan luar. Begitu test bergantung pada DNS, latensi, rate limit, atau sandbox pihak ketiga, ia berubah menjadi test integrasi lingkungan dan rawan gagal acak.

5. JSON comparison yang terlalu kaku

Membandingkan string JSON mentah sering gagal hanya karena urutan field berbeda. Bandingkan sebagai objek terstruktur, lalu validasi field yang penting.

Tips debugging: saat contract test gagal, log-kan request, status code, header penting, dan body response yang sudah dipretty-print. Kegagalan kontrak sering lebih cepat dipahami dari diff payload dibanding dari stack trace parser.

Memvalidasi perubahan field, status code, dan error response

Perubahan field

Perubahan kontrak yang paling umum:

  • Rename field, misalnya user_id menjadi id.
  • Menghapus field yang dipakai consumer.
  • Mengubah tipe data, misalnya integer menjadi string.
  • Menjadikan field nullable tanpa pemberitahuan.

Secara umum:

  • Menambah field baru sering aman jika consumer mengabaikan field yang tidak dipakai.
  • Menghapus atau mengganti nama field hampir selalu breaking.
  • Mengubah tipe data adalah breaking meskipun nama field tetap.

Di Rust, perubahan tipe akan cepat terdeteksi saat deserialisasi gagal, yang justru menjadi keuntungan karena masalah muncul lebih awal.

Perubahan status code

Status code adalah bagian penting dari kontrak. Consumer sering memakai status code untuk flow control. Misalnya:

  • 404 berarti user tidak ada.
  • 409 berarti konflik bisnis.
  • 422 berarti validasi gagal.

Jika provider mengubah 404 menjadi 200 dengan body kosong, secara teknis endpoint masih merespons, tetapi kontraknya rusak. Contract test harus memeriksa status code sebagai assertion eksplisit, bukan hanya body.

Error response

Banyak tim hanya mengetes jalur sukses, lalu produksi rusak ketika format error berubah. Minimal, sediakan contract test untuk:

  • Not found.
  • Validation error.
  • Unauthorized atau forbidden jika memang relevan.
  • Conflict untuk operasi write.

Jika format error Anda seragam, dokumentasikan dan uji bentuk minimumnya. Contoh:

{
  "code": "VALIDATION_ERROR",
  "message": "email is invalid",
  "details": {
    "field": "email"
  }
}

Bila details opsional, nyatakan itu jelas dalam kontrak dan jangan membuat consumer mengasumsikan field tersebut selalu ada.

Versioning kontrak tanpa membuat maintenance meledak

Versioning perlu dilakukan saat ada perubahan yang tidak kompatibel ke belakang. Tujuannya bukan menambah kompleksitas, tetapi memberi jalur migrasi yang aman.

Kapan perlu versi baru

  • Menghapus field yang dipakai consumer.
  • Mengubah tipe field.
  • Mengubah status code untuk skenario yang sama.
  • Mengubah makna domain dari field atau nilai enum.

Kapan biasanya tidak perlu versi baru

  • Menambah field baru yang bisa diabaikan consumer.
  • Menambah endpoint baru tanpa memengaruhi endpoint lama.
  • Menambah nilai enum baru, jika consumer memang dirancang menangani unknown value dengan aman. Jika tidak, ini tetap berpotensi breaking.

Pendekatan versioning yang realistis

Untuk tim kecil, jangan mulai dengan skema kompleks. Cukup:

  • Simpan file kontrak per endpoint dan versi, misalnya user_get_by_id_v1.json.
  • Jika ada breaking change, buat v2 dan jalankan verifikasi untuk v1 dan v2 selama masa transisi.
  • Tetapkan tanggal atau milestone penghentian versi lama.

Hal yang lebih penting daripada format versioning adalah disiplin menandai mana perubahan breaking dan mana yang kompatibel.

Alur verifikasi contract test API di CI

Agar berguna, contract test harus menjadi bagian dari alur perubahan kode, bukan hanya dijalankan manual.

Alur yang disarankan

  1. Consumer memperbarui atau menambah kontrak berdasarkan kebutuhan baru.
  2. Consumer menjalankan test lokal terhadap mock server.
  3. File kontrak dipublikasikan atau disimpan di lokasi yang bisa diakses provider.
  4. Provider menjalankan verifikasi terhadap kontrak tersebut di CI.
  5. Pull request provider gagal jika implementasi tidak lagi memenuhi kontrak aktif.

Pipeline minimum untuk tim kecil

  • Job 1: unit test biasa.
  • Job 2: consumer contract test dengan mock provider.
  • Job 3: provider verification terhadap kontrak yang dipakai consumer.

Dengan model ini, Anda tidak harus menyalakan seluruh lingkungan staging hanya untuk tahu bahwa field status berubah nama.

Apa yang perlu diarsipkan di CI

  • File kontrak yang diverifikasi.
  • Log request/response saat gagal.
  • Diff payload atau assertion yang tidak cocok.

Artefak ini penting agar tim provider dan consumer bisa mendiskusikan perubahan dengan bukti yang jelas, bukan dugaan.

Trade-off biaya maintenance dan batasan contract testing

Contract testing bukan gratis. Ia menambah artefak, review, dan pipeline baru. Karena itu, penting memahami biaya dan manfaatnya.

Biaya yang nyata

  • Perlu menjaga file kontrak tetap relevan.
  • Perlu fixture dan skenario error yang rapi.
  • Perlu koordinasi antartim saat kontrak berubah.
  • Perlu disiplin agar kontrak tidak terlalu detail.

Kesalahan umum yang membuat biaya membengkak

  • Mengunci terlalu banyak detail yang tidak dipakai consumer.
  • Menjadikan semua field wajib padahal sebagian opsional.
  • Menggunakan data acak dan state bersama.
  • Menganggap contract test bisa menggantikan end-to-end test sepenuhnya.

Batasan contract testing

  • Tidak membuktikan seluruh sistem terhubung benar di lingkungan nyata.
  • Tidak menggantikan pengujian performa, security, atau resiliency.
  • Tidak otomatis menangkap bug logika bisnis internal jika bentuk API tetap sama.

Karena itu, contract test paling efektif jika dipakai untuk menjaga antarmuka tetap stabil, bukan sebagai satu-satunya strategi quality assurance.

Checklist implementasi bertahap untuk tim kecil

Jika tim Anda belum pernah menjalankan contract test API, mulai kecil dan fokus pada endpoint yang paling sering menyebabkan insiden integrasi.

  1. Pilih 1 consumer dan 1 provider yang kritis.
  2. Ambil 1 endpoint read-only lebih dulu, misalnya GET /users/{id}.
  3. Definisikan kontrak minimum: method, path, status code, body sukses, dan body error utama.
  4. Buat fixture statis untuk skenario sukses dan not found.
  5. Tambahkan test consumer terhadap mock server.
  6. Tambahkan test provider yang memverifikasi implementasi lokal terhadap kontrak.
  7. Masukkan ke CI sebagai job terpisah agar mudah dibaca saat gagal.
  8. Review perubahan kontrak di pull request seperti halnya perubahan schema database.
  9. Tambahkan endpoint write setelah pola stabil, termasuk validasi error 400 atau 409.
  10. Tetapkan aturan breaking change dan kapan versi baru harus dibuat.

Penutup

Untuk backend Rust yang saling terhubung lewat HTTP JSON, contract test adalah cara praktis untuk mencegah regresi integrasi sebelum perubahan mencapai staging atau produksi. Nilai utamanya bukan pada jumlah test, melainkan pada ketepatan memverifikasi hal-hal yang benar-benar dipakai antarservis: field penting, tipe data, status code, dan error response.

Mulailah dari satu integrasi yang sering bermasalah, buat fixture yang deterministik, dan pasang verifikasi di CI. Dengan pendekatan itu, Anda mendapat sinyal yang lebih cepat dan lebih stabil dibanding hanya mengandalkan unit test atau end-to-end test penuh.