Strategi uji kontrak modul dibutuhkan ketika unit test masih hijau tetapi integrasi antar-komponen tetap rusak. Masalahnya biasanya bukan pada logika lokal, melainkan pada asumsi yang berubah diam-diam di batas modul: format data, urutan field, perilaku error, nilai default, atau invariant yang tidak pernah ditulis eksplisit.

Untuk mencegah regresi seperti ini, tim perlu memperlakukan batas antarmuka sebagai sesuatu yang bisa diverifikasi terpisah. Gagasan ini sejalan dengan konteks How a Computer Should Work: sistem yang sehat dibangun dari komponen dengan batas jelas, asumsi eksplisit, dan perilaku yang dapat diuji secara independen. Dalam praktik backend atau tool internal, itu berarti setiap modul harus punya kontrak yang bisa dibaca, diuji, dan dijaga kompatibilitasnya dari waktu ke waktu.

Kapan unit test tidak cukup

Unit test efektif untuk memastikan fungsi atau kelas bekerja sesuai harapan dalam isolasi. Namun unit test sering gagal menangkap perubahan yang terjadi di titik pertemuan antar-modul. Contohnya:

  • Modul A mengirim field user_id sebagai string, sementara Modul B mulai mengharapkan integer.
  • Produser event menambah field wajib baru tanpa fallback untuk konsumen lama.
  • Repository mengubah perilaku dari null menjadi error terstruktur, tetapi caller belum diperbarui.
  • Formatter tanggal berubah dari UTC ke waktu lokal tanpa dokumentasi kontrak.
  • Urutan eksekusi atau efek samping berubah sehingga modul lain membaca state yang belum konsisten.

Dalam kasus ini, masing-masing modul bisa tetap lulus unit test karena mock atau fixture mereka terlalu ideal. Yang rusak adalah kesepakatan di antara modul, bukan implementasi internalnya.

Jika sebuah modul hanya bisa diuji dengan mengandalkan pengetahuan tersembunyi tentang implementasi modul lain, batas antarmukanya belum cukup jelas.

Prinsip dasar strategi uji kontrak modul

1. Definisikan boundary yang nyata

Boundary modul adalah titik di mana data atau perintah melintasi komponen. Pada backend, boundary umum meliputi:

  • pemanggilan service internal,
  • repository ke database abstraction,
  • producer dan consumer queue/event,
  • CLI tool ke library internal,
  • adapter ke layanan eksternal,
  • serializer/deserializer request atau response.

Jangan mulai dari seluruh sistem. Pilih boundary yang paling sering berubah atau paling sering menimbulkan bug integrasi.

2. Tulis asumsi secara eksplisit

Kontrak bukan hanya daftar field. Kontrak juga mencakup:

  • Input: bentuk data, field wajib, field opsional, tipe, range nilai, encoding, dan default.
  • Output: struktur hasil, nilai yang dijamin ada, semantik status, serta perilaku ketika data tidak ditemukan.
  • Error contract: jenis error yang mungkin muncul, kapan dikembalikan, dan mana yang dapat dipulihkan caller.
  • Invariant: kondisi yang harus selalu benar, misalnya total tidak boleh negatif, ID harus stabil, timestamp harus monoton naik untuk revisi tertentu.
  • Compatibility rule: perubahan apa yang dianggap kompatibel dan apa yang dianggap breaking.

Asumsi yang tidak tertulis hampir selalu menjadi sumber regresi integrasi.

3. Verifikasi komponen secara terpisah

Setiap modul sebaiknya dapat diuji terhadap kontrak tanpa harus menyalakan keseluruhan sistem. Ini membuat kegagalan lebih mudah dilokalisasi dan proses CI lebih cepat. Pendekatannya bisa berupa:

  • Provider contract test: memastikan modul penyedia benar-benar menghasilkan perilaku sesuai kontrak.
  • Consumer contract test: memastikan modul pemakai hanya mengandalkan bagian kontrak yang resmi, bukan detail insidental.
  • Compatibility test: memastikan versi baru masih bisa berinteraksi dengan ekspektasi lama yang masih didukung.

Menentukan kontrak input/output dan invariant

Cara praktisnya adalah memilih satu boundary penting lalu menuliskan kontraknya dalam format yang bisa direview. Tidak harus memakai tool khusus di awal; dokumen sederhana plus test yang konsisten sering lebih berguna daripada skema rumit yang tidak dijaga.

Contoh boundary: modul pembuat invoice dan modul notifikasi

Misalkan backend memiliki modul InvoiceService yang menerbitkan event invoice.created, lalu modul NotificationWorker mengonsumsi event tersebut untuk mengirim email. Kontrak minimal yang perlu jelas:

  • Nama event dan versinya.
  • Field wajib: invoice_id, customer_email, total_amount, currency, created_at.
  • Format field: misalnya total_amount dalam satuan minor unit, bukan float.
  • Field opsional dan semantiknya.
  • Perilaku jika email kosong atau invoice dibatalkan.
  • Invariant: total_amount >= 0, currency harus kode yang didukung, invoice_id unik dan stabil.

Contoh representasi kontrak dalam bentuk yang mudah dibaca:

Event: invoice.created
Version: v1

Required fields:
- invoice_id: string, non-empty
- customer_email: string, valid email
- total_amount: integer, minor unit, >= 0
- currency: string, uppercase, supported code
- created_at: RFC3339 timestamp in UTC

Optional fields:
- due_at: RFC3339 timestamp in UTC
- metadata: object

Error assumptions:
- Producer never emits partially built payload
- Consumer may reject invalid payload and log structured error

Compatibility rules:
- Adding optional fields: allowed
- Renaming/removing required fields: breaking
- Changing numeric unit from minor to major: breaking
- Changing timestamp format: breaking

Dengan format seperti ini, tim dapat langsung menerjemahkannya ke test case.

Invariant yang sering terlupakan

Tim sering fokus pada tipe data, tetapi lupa pada invariant bisnis dan operasional. Padahal justru invariant ini yang banyak menyebabkan bug integrasi. Contoh invariant penting:

  • Field ID tidak boleh didaur ulang untuk entitas berbeda.
  • Urutan status harus valid, misalnya draft -> issued -> paid, bukan melompat semaunya.
  • Event tidak boleh mengandung PII tertentu jika melewati boundary tertentu.
  • Retry tidak boleh menghasilkan efek samping ganda tanpa idempotency key.
  • Field opsional yang kosong tetap harus dibedakan dari field yang tidak dikirim.

Jenis uji kontrak yang paling berguna

1. Contract test dasar di provider dan consumer

Pola paling sederhana adalah membuat kumpulan fixture resmi, lalu dipakai oleh dua sisi. Provider memastikan output-nya cocok dengan fixture dan aturan validasi. Consumer memastikan ia dapat memproses fixture valid dan menolak fixture invalid dengan cara yang konsisten.

Contoh pseudocode yang bisa diadaptasi ke berbagai bahasa:

// provider_contract_test
func TestInvoiceCreatedEvent_ConformsToContract(t *testing.T) {
    event := buildInvoiceCreatedEvent(sampleInvoice())

    assertField(t, event, "invoice_id", isNonEmptyString)
    assertField(t, event, "customer_email", isValidEmail)
    assertField(t, event, "total_amount", isNonNegativeInteger)
    assertField(t, event, "currency", isSupportedCurrency)
    assertField(t, event, "created_at", isRFC3339UTC)
}

// consumer_contract_test
func TestNotificationWorker_AcceptsValidInvoiceCreatedEvent(t *testing.T) {
    event := loadFixture("invoice_created_valid.json")
    result := consumeInvoiceCreated(event)
    assertNoError(t, result)
}

func TestNotificationWorker_RejectsInvalidAmount(t *testing.T) {
    event := loadFixture("invoice_created_invalid_amount.json")
    err := consumeInvoiceCreated(event)
    assertErrorCode(t, err, "INVALID_EVENT_PAYLOAD")
}

Kuncinya bukan bahasa atau framework, melainkan satu sumber kebenaran kontrak yang diuji oleh kedua pihak.

2. Compatibility test untuk perubahan bertahap

Compatibility test memastikan modul baru tetap kompatibel dengan kontrak yang masih didukung. Ini penting ketika Anda tidak bisa meng-upgrade semua konsumen sekaligus.

Contoh skenario yang layak diuji:

  • Provider baru tetap menghasilkan field wajib lama.
  • Consumer baru masih bisa membaca payload versi sebelumnya.
  • Penambahan field opsional tidak memengaruhi parser lama.
  • Nilai enum baru diperlakukan aman oleh consumer lama, misalnya masuk jalur fallback.

Compatibility test sangat berguna untuk sistem event-driven, worker asinkron, dan tool internal yang dipakai banyak tim.

3. Golden test untuk format yang harus stabil

Golden test cocok saat output harus stabil dan perubahan kecil pun signifikan, misalnya:

  • serialisasi JSON yang dikonsumsi modul lain,
  • query atau plan yang dihasilkan generator internal,
  • output CLI yang diparsing tool lain,
  • template konfigurasi atau file manifest.

Prinsipnya, hasil aktual dibandingkan dengan file referensi yang disetujui. Jika berbeda, reviewer memeriksa apakah perubahan itu memang disengaja.

// contoh alur golden test
actual := renderInvoicePayload(sampleInvoice())
expected := loadFile("testdata/invoice_created.golden.json")
assertEqualNormalizedJSON(t, expected, actual)

Trade-off-nya: golden test mudah dipakai, tetapi bisa menjadi beban jika output terlalu sering berubah atau snapshot diperbarui tanpa review serius. Jangan jadikan golden file sekadar artefak yang di-regenerate otomatis tanpa analisis.

4. Smoke test di CI untuk boundary penting

Smoke test bukan pengganti contract test. Tujuannya adalah memastikan rangkaian integrasi minimum masih hidup setelah perubahan. Pilih alur sempit namun kritis, misalnya:

  1. bangun payload dari provider,
  2. lewatkan ke consumer sungguhan atau adapter tipis,
  3. verifikasi status sukses dan satu-dua efek utama.

Smoke test sebaiknya cepat, deterministik, dan hanya mencakup path yang paling penting. Jika terlalu luas, ia berubah menjadi integration test berat yang lambat dan sulit didiagnosis.

Workflow verifikasi sebelum merge

Tim sering punya test, tetapi belum punya urutan verifikasi yang konsisten. Berikut workflow yang praktis untuk pull request yang menyentuh boundary modul:

  1. Identifikasi boundary yang berubah
    Contoh: payload event, return type service, format file, atau perilaku error.
  2. Tandai jenis perubahan
    Apakah ini kompatibel, deprecating, atau breaking.
  3. Perbarui dokumen kontrak atau fixture resmi
    Jangan mengubah test tanpa mengubah definisi kontrak jika perilakunya memang bergeser.
  4. Jalankan provider contract test
    Pastikan output baru sesuai kontrak yang dinyatakan.
  5. Jalankan consumer contract test
    Pastikan konsumen tidak bergantung pada detail yang tidak dijamin.
  6. Jalankan compatibility test
    Wajib jika ada versi lama yang masih didukung.
  7. Jalankan golden test
    Terutama jika boundary berupa format serialisasi atau output stabil.
  8. Jalankan smoke test CI
    Pastikan jalur integrasi minimal masih berfungsi.
  9. Review perubahan kontrak secara eksplisit
    Pisahkan komentar tentang implementasi internal dan komentar tentang antarmuka.

Untuk tim kecil, workflow ini bisa diimplementasikan tanpa infrastruktur rumit: folder fixture bersama, skrip test standar, dan satu checklist PR sudah cukup sebagai awal.

Contoh pembagian stage CI

stage: lint
- static analysis
- schema/contract format validation

stage: test-fast
- unit tests
- provider contract tests
- consumer contract tests

stage: test-compat
- backward compatibility tests
- golden tests

stage: smoke
- minimal end-to-end path across module boundary

Pemisahan ini membantu diagnosis. Jika gagal di stage kontrak, tim tahu masalahnya ada di boundary, bukan di seluruh sistem.

Tanda-tanda flaky test pada boundary modul

Flaky test di area boundary sangat berbahaya karena bisa menutupi regresi nyata. Beberapa tanda yang patut dicurigai:

  • Test kadang gagal hanya di CI, tetapi lolos di lokal.
  • Gagal saat dijalankan paralel, lolos saat serial.
  • Snapshot atau golden file berubah karena urutan map/object tidak stabil.
  • Perbandingan timestamp gagal karena timezone atau presisi berbeda.
  • Test event/queue bergantung pada waktu tunggu tetap seperti sleep(2).
  • Data fixture dipakai bersama lintas test dan termodifikasi secara tidak sengaja.
  • Mock terlalu permisif sehingga kadang menyembunyikan invalid contract.

Cara mengurangi flaky test boundary

  • Bekukan waktu dengan clock abstraction jika boundary melibatkan timestamp.
  • Normalisasi output sebelum dibandingkan, misalnya urutan field JSON jika semantiknya memang tidak bergantung urutan.
  • Hindari menunggu berbasis durasi tetap; tunggu berdasarkan kondisi atau sinyal.
  • Pastikan fixture immutable atau di-clone per test.
  • Pisahkan test deterministic dari test yang benar-benar membutuhkan I/O nyata.
  • Jangan gunakan data acak tanpa seed yang dapat direproduksi.

Jika sebuah test boundary sering gagal tanpa pola jelas, perlakukan itu sebagai bug desain test atau bug kontrak, bukan gangguan kecil yang bisa diabaikan.

Contoh struktur test case yang bisa langsung diadopsi tim

Berikut struktur yang sederhana dan efektif untuk banyak boundary modul.

Struktur folder

contracts/
  invoice_created/
    contract.md
    fixtures/
      valid_minimal.json
      valid_full.json
      invalid_missing_invoice_id.json
      invalid_negative_total.json
      backward_v1.json
    provider_test.*
    consumer_test.*
    compatibility_test.*
    golden_test.*

Kategori test case

  • Valid minimal: hanya field wajib.
  • Valid penuh: field wajib + opsional.
  • Invalid shape: field hilang, tipe salah, format salah.
  • Invalid invariant: nilai negatif, status tidak valid, timestamp tidak konsisten.
  • Backward compatible: payload versi lama yang masih harus diterima.
  • Forward tolerance: field tambahan yang harus diabaikan aman oleh consumer jika memang diperbolehkan.
  • Error contract: invalid input menghasilkan kode atau kategori error yang diharapkan.

Template test case review

Nama kasus: valid_full_invoice_created
Boundary: InvoiceService -> NotificationWorker
Tujuan: memastikan payload lengkap versi saat ini diterima consumer
Input:
- invoice_id valid
- customer_email valid
- total_amount non-negative
- optional metadata terisi
Ekspektasi:
- parsing sukses
- email job terbentuk
- tidak ada fallback/error path
Invariant yang diperiksa:
- total_amount dalam minor unit
- created_at format UTC RFC3339
Catatan kompatibilitas:
- field tambahan harus diabaikan consumer lama

Format seperti ini memudahkan reviewer memahami maksud test tanpa membaca seluruh implementasi.

Checklist review untuk perubahan pada boundary modul

Saat code review, gunakan checklist khusus untuk kontrak. Ini lebih efektif daripada hanya mengandalkan reviewer mengingat semua implikasi sendiri.

  • Apakah boundary yang berubah disebutkan jelas di PR?
  • Apakah perubahan ini kompatibel atau breaking?
  • Apakah kontrak input/output diperbarui bersama implementasi?
  • Apakah invariant penting ditulis dan diuji?
  • Apakah provider dan consumer test sama-sama ada atau diperbarui?
  • Apakah ada compatibility test jika masih ada konsumen lama?
  • Apakah golden file yang berubah benar-benar direview isi semantiknya?
  • Apakah error contract tetap konsisten untuk caller?
  • Apakah timestamp, locale, encoding, dan urutan data sudah dipertimbangkan?
  • Apakah smoke test CI mencakup jalur minimal yang terdampak?

Kesalahan umum saat menerapkan uji kontrak

Kontrak terlalu longgar

Jika kontrak hanya menyatakan “JSON valid”, itu belum cukup. Kontrak harus memuat semantik yang benar-benar dipakai modul lain.

Kontrak terlalu ketat pada detail yang tidak penting

Misalnya memaksa urutan field JSON padahal parser tidak bergantung pada urutan. Kontrak yang terlalu ketat membuat perubahan aman terlihat seperti breaking.

Semua diuji lewat end-to-end

End-to-end test penting, tetapi terlalu mahal untuk menjadi alat utama penjaga boundary. Saat gagal, akar masalah juga sulit ditemukan.

Fixture tidak mencerminkan dunia nyata

Gunakan contoh payload yang realistis, termasuk nilai kosong, nilai ekstrem yang valid, dan kasus error yang benar-benar pernah terjadi.

Snapshot disetujui tanpa review

Golden test hanya berguna jika perubahan file referensi dibaca secara kritis. Jika tidak, ia berubah menjadi formalitas.

Memilih pendekatan yang tepat

Tidak semua modul membutuhkan tingkat verifikasi yang sama. Pilih berdasarkan risiko:

  • Boundary internal sederhana dan jarang berubah: contract test dasar + smoke test biasanya cukup.
  • Event-driven atau asinkron: tambahkan compatibility test karena upgrade sering tidak serentak.
  • Output serialisasi stabil: tambahkan golden test.
  • Boundary kritis dengan banyak konsumen: dokumentasi kontrak formal, fixture resmi, dan review checklist wajib.

Tujuannya bukan menambah sebanyak mungkin jenis test, melainkan menempatkan pengaman yang tepat di titik regresi yang paling sering terjadi.

Penutup

Strategi uji kontrak modul efektif untuk mencegah regresi integrasi karena ia memindahkan fokus dari “apakah fungsi ini benar?” menjadi “apakah dua komponen masih sepakat tentang cara berinteraksi?”. Dengan boundary yang jelas, asumsi eksplisit, invariant yang diuji, serta kombinasi contract test, compatibility test, golden test, dan smoke test CI, tim bisa menangkap masalah lebih awal sebelum merge.

Mulailah dari satu boundary yang paling sering rusak. Tulis kontraknya, siapkan fixture resmi, buat test di kedua sisi, lalu pasang checklist review. Setelah itu, kualitas integrasi biasanya membaik bukan karena sistem menjadi lebih rumit, tetapi karena batas antar-modul akhirnya menjadi sesuatu yang benar-benar bisa diverifikasi.