Test strategy yang baik bukan dimulai dari daftar tool atau persentase coverage, tetapi dari pertanyaan yang lebih mendasar: apa sebenarnya yang ingin dijamin oleh software ini? Tanpa definisi jaminan yang jelas, tim sering menulis banyak test tetapi tetap gagal mencegah bug yang benar-benar penting.

Praktiknya, tim perlu mengubah kata-kata seperti “sistem harus andal” atau “API tidak boleh rusak” menjadi jaminan yang bisa diverifikasi. Dari situ barulah kita memilih jenis test yang tepat: unit, integration, contract, end-to-end, property-based, atau smoke tests. Artikel ini membahas cara melakukannya secara praktis, dengan fokus pada jaminan yang paling umum: correctness, invariants, backward compatibility, failure handling, dan observability.

Mengapa test strategy harus dimulai dari jaminan, bukan dari jenis test

Banyak tim memulai dari pertanyaan seperti “berapa banyak unit test yang kita butuhkan?” atau “perlu tidak E2E test?”. Itu pertanyaan turunan. Pertanyaan utamanya adalah:

  • Perilaku apa yang wajib selalu benar?
  • Kondisi apa yang tidak boleh pernah dilanggar?
  • Perubahan apa yang tidak boleh memutus integrasi lama?
  • Kegagalan seperti apa yang harus ditangani secara aman?
  • Jika sistem rusak, bukti apa yang tersedia untuk mendeteksi dan mendiagnosisnya?

Jika jawaban atas lima area ini tidak eksplisit, test suite biasanya jatuh ke dua ekstrem:

  • Terlalu dangkal: banyak unit test untuk detail implementasi, tetapi hampir tidak ada verifikasi terhadap perilaku bisnis penting.
  • Terlalu mahal: terlalu banyak E2E test yang lambat, rapuh, dan sulit dipelihara.

Pendekatan yang lebih efektif adalah memetakan setiap jaminan ke level verifikasi termurah yang masih cukup meyakinkan. Prinsip sederhananya:

Uji pada level serendah mungkin, tetapi setinggi yang diperlukan untuk membuktikan jaminan.

Klasifikasi jaminan software yang perlu didefinisikan

1. Correctness

Correctness berarti input tertentu menghasilkan output atau efek yang benar sesuai aturan bisnis. Contohnya:

  • Total invoice harus sesuai item, diskon, dan pajak.
  • Role viewer tidak boleh menghapus data.
  • Endpoint pencarian mengembalikan hasil yang relevan sesuai filter.

Jaminan ini biasanya paling mudah diuji, tetapi juga paling mudah disalahartikan. Kesalahan umum adalah menguji detail implementasi internal alih-alih perilaku yang diminta bisnis.

2. Invariants

Invariant adalah kondisi yang harus selalu benar, terlepas dari urutan operasi atau kondisi sistem. Contoh:

  • Saldo akun tidak boleh negatif jika produk tidak mengizinkan overdraft.
  • Satu order tidak boleh dibayar dua kali.
  • ID eksternal harus unik.
  • Status resource tidak boleh melompat dari draft langsung ke archived jika workflow melarangnya.

Invariant biasanya lebih penting daripada satu hasil fungsi tertentu, karena ia melindungi konsistensi sistem secara menyeluruh.

3. Backward compatibility

Jaminan ini memastikan perubahan baru tidak merusak konsumen yang sudah ada. Biasanya berlaku pada:

  • API HTTP/JSON
  • Schema event
  • Format file
  • CLI behavior
  • Database migration yang dibaca oleh versi aplikasi lama

Masalah kompatibilitas jarang terlihat di unit test biasa, karena kerusakan terjadi di batas antar sistem.

4. Failure handling

Software yang baik bukan hanya bekerja saat semua normal, tetapi juga gagal dengan aman. Contohnya:

  • Retry tidak menyebabkan duplikasi transaksi.
  • Timeout dependency tidak membuat worker macet permanen.
  • Jika database utama gagal sesaat, request gagal dengan status yang tepat dan tidak mengembalikan data salah.
  • Partial failure tidak meninggalkan state setengah jadi tanpa kompensasi.

Jaminan ini sering diabaikan karena lebih sulit diuji daripada jalur sukses.

5. Observability

Observability bukan sekadar “punya log”. Jaminannya adalah: saat sesuatu salah, tim dapat mendeteksi, memahami, dan menelusuri penyebabnya dengan sinyal yang cukup. Contoh:

  • Error penting tercatat dengan konteks request dan correlation ID.
  • Metrik tersedia untuk latency, error rate, dan queue backlog.
  • Alert dibangun di atas sinyal yang stabil, bukan noise.
  • Trace atau log memungkinkan penelusuran alur lintas service.

Jaminan observability penting karena bug produksi yang tak terdeteksi sama berbahayanya dengan bug yang tak diuji.

Menerjemahkan jaminan ke jenis test yang tepat

Tidak ada satu jenis test yang cocok untuk semua jaminan. Pilihan test harus mengikuti risiko, biaya eksekusi, dan posisi bug muncul.

Unit tests: cepat untuk correctness lokal

Pilih unit test ketika aturan dapat dibuktikan pada fungsi, class, atau modul terisolasi tanpa infrastruktur nyata. Cocok untuk:

  • Perhitungan harga, pajak, diskon
  • Validasi input
  • Aturan otorisasi murni
  • Transisi status yang tidak bergantung pada I/O

Kelebihan:

  • Cepat
  • Mudah dijalankan di setiap commit
  • Bagus untuk eksplorasi edge case

Keterbatasan:

  • Tidak membuktikan wiring antar komponen
  • Tidak mendeteksi salah konfigurasi database, queue, network, atau serialization

Integration tests: membuktikan perilaku lintas komponen nyata

Integration test cocok saat jaminan melibatkan interaksi nyata antara aplikasi dan dependency internal, misalnya database, cache, message broker, filesystem, atau ORM.

Contoh penggunaan:

  • Invariant unik benar-benar ditegakkan oleh database constraint
  • Worker memproses job dan menulis status ke database
  • Transaction rollback bekerja saat operasi kedua gagal

Kelebihan:

  • Lebih realistis daripada mocking berlebihan
  • Baik untuk memverifikasi konfigurasi dan data model

Keterbatasan:

  • Lebih lambat
  • Perlu setup lingkungan yang konsisten

Contract tests: fokus untuk backward compatibility

Contract test berguna di batas antar service atau antara provider dan consumer. Tujuannya bukan menguji seluruh alur, tetapi memastikan bentuk request/response atau event tetap kompatibel.

Cocok untuk:

  • API publik/internal
  • Event schema di message bus
  • Payload webhook

Kelebihan:

  • Lebih murah daripada E2E lintas banyak service
  • Efektif mencegah perubahan breaking yang tidak disengaja

Keterbatasan:

  • Tidak membuktikan semua perilaku bisnis end-to-end
  • Memerlukan disiplin versioning dan artifact contract

End-to-end tests: hanya untuk jaminan lintas sistem yang kritis

End-to-end test sebaiknya dipakai secara selektif untuk beberapa alur yang paling penting, misalnya:

  • User login lalu checkout berhasil
  • Order dibuat, dibayar, dan muncul di backoffice
  • Alur signup menghasilkan akun aktif dan email verifikasi terkirim

Kelebihan:

  • Membuktikan integrasi nyata dari perspektif pengguna

Keterbatasan:

  • Lambat
  • Flaky jika terlalu bergantung pada timing, UI, atau dependency eksternal
  • Mahal dirawat

Gunakan E2E untuk membuktikan bahwa “jalur utama sistem benar-benar hidup”, bukan untuk menutup semua kombinasi edge case.

Property-based tests: kuat untuk invariants dan edge cases

Property-based testing cocok saat Anda ingin membuktikan sifat umum, bukan satu contoh input. Misalnya:

  • Total pembayaran tidak boleh negatif untuk kombinasi item apa pun
  • Serialisasi lalu deserialisasi mempertahankan data penting
  • Operasi penambahan dan pembatalan item tidak melanggar invariant stok

Keunggulannya adalah test menghasilkan banyak variasi input otomatis, sehingga sering menemukan bug yang luput dari contoh manual.

Smoke tests: verifikasi minimum setelah deploy

Smoke test bukan pengganti test lain. Tujuannya memastikan sistem yang baru dibangun atau baru dirilis setidaknya:

  • Bisa start
  • Bisa terhubung ke dependency penting
  • Endpoint atau job utama merespons
  • Migration dasar tidak merusak startup

Smoke test sangat berguna di CI maupun segera setelah deploy ke staging/production.

Memetakan setiap jaminan ke strategi verifikasi

Jaminan correctness

Mulailah dari unit test untuk aturan bisnis murni. Tambahkan integration test hanya bila correctness bergantung pada persistence, transaction, atau query nyata.

Contoh: perhitungan invoice.

function calculateInvoiceTotal(items, discount, taxRate) {
  const subtotal = items.reduce((sum, item) => sum + item.price * item.qty, 0);
  const discounted = Math.max(0, subtotal - discount);
  return Math.round(discounted * (1 + taxRate));
}

Untuk fungsi seperti ini, unit test cukup kuat. Tetapi jika aturan “diskon hanya berlaku untuk customer tier tertentu” berasal dari database atau service lain, maka integration test mungkin dibutuhkan.

Jaminan invariants

Invariant sebaiknya dibuktikan di beberapa lapisan:

  • Unit test untuk aturan domain
  • Integration test untuk constraint nyata di database atau mekanisme concurrency
  • Property-based test untuk urutan operasi yang bervariasi

Contoh invariant: satu order tidak boleh dibayar dua kali. Unit test bisa memverifikasi transisi status. Namun jika sistem bisa menerima dua request bersamaan, Anda perlu integration test terhadap transaction atau unique constraint agar invariant tetap benar saat race condition terjadi.

Jaminan backward compatibility

Gunakan contract test sebagai garis depan. Jika sistem berbasis event atau API, definisikan field mana yang wajib, opsional, dan boleh ditambahkan tanpa memutus klien lama.

Hal yang perlu diverifikasi:

  • Field lama tidak hilang tiba-tiba
  • Tipe data tidak berubah tanpa versioning
  • Nilai enum baru tidak mematahkan consumer lama
  • Perubahan error response tidak merusak parsing klien

Untuk release berisiko tinggi, tambahkan smoke test atau compatibility suite terhadap satu atau dua consumer nyata yang kritis.

Jaminan failure handling

Jaminan ini biasanya membutuhkan kombinasi integration dan end-to-end test ringan. Beberapa skenario yang layak diuji:

  • Timeout ke dependency menghasilkan retry terbatas, bukan loop tanpa akhir
  • Duplicate delivery pada queue tetap idempotent
  • Proses yang gagal di tengah meninggalkan state yang aman
  • Circuit breaker atau fallback aktif saat dependency bermasalah

Mock bisa membantu mensimulasikan error, tetapi jangan hanya mengandalkan mock jika perilaku nyata dipengaruhi timeout, network, transaction, atau concurrency.

Jaminan observability

Observability bisa dan sebaiknya diuji. Bentuk test-nya berbeda dari correctness biasa. Beberapa contoh:

  • Integration test memverifikasi error penting tercatat dengan atribut kunci seperti request ID
  • Smoke test memastikan endpoint health dan metrics tersedia
  • Test pada pipeline log memastikan format log tetap terstruktur

Yang diuji bukan semua isi log, tetapi kontrak minimum yang dibutuhkan untuk operasi: nama event, severity, correlation key, dan konteks penting.

Contoh test matrix yang realistis

Berikut contoh test matrix untuk service order yang memiliki API, database, worker async, dan integrasi pembayaran.

JaminanRisikoJenis Test UtamaTambahan
Harga order benarSalah tagihUnitProperty-based untuk kombinasi item/diskon
Order tidak dibayar dua kaliDouble chargeIntegrationUnit untuk state transition
API create order kompatibelConsumer rusakContractSmoke terhadap consumer kritis
Retry webhook amanDuplikasi side effectIntegrationE2E untuk satu alur kritis
Error pembayaran terdeteksiIncident terlambat diketahuiIntegration observabilitySmoke metrics/logging setelah deploy
Checkout dasar tetap hidupRegresi produksiE2E terbatasSmoke pasca-deploy

Beberapa hal penting dari matrix seperti ini:

  • Tidak semua jaminan harus punya E2E test.
  • Satu jaminan penting boleh diverifikasi di lebih dari satu level jika risikonya tinggi.
  • Coverage diukur terhadap jaminan, bukan sekadar file atau baris kode.

Cara membatasi scope verifikasi agar test suite tetap sehat

Salah satu kesalahan terbesar adalah mencoba membuktikan semua hal di semua level. Hasilnya test duplikatif, lambat, dan sulit dipahami.

Batasi pertanyaan setiap test

Satu test harus menjawab satu klaim yang jelas. Contoh yang baik:

  • “Jika payment provider timeout, job dijadwalkan ulang maksimal 3 kali.”
  • “Response API tetap mengandung field order_id dan status.”
  • “Saldo tidak menjadi negatif setelah urutan debit-kredit acak.”

Contoh yang buruk:

  • “Semua proses checkout berjalan.”

Pernyataan terlalu luas membuat diagnosis sulit saat test gagal.

Hindari overlap tanpa alasan

Jika satu aturan bisnis sudah kuat dibuktikan di unit test, jangan salin semua kombinasi yang sama ke E2E. Gunakan E2E hanya untuk satu atau dua alur representatif yang membuktikan wiring penuh.

Pilih sampel skenario, bukan semua kombinasi

Untuk domain kompleks, gunakan:

  • Unit/property-based untuk eksplorasi kombinasi
  • E2E untuk happy path + satu failure path penting
  • Contract untuk batas antar service

Ini memberi keyakinan tinggi tanpa membuat pipeline terlalu lambat.

Anti-pattern flaky tests dan cara menghindarinya

1. Bergantung pada waktu nyata

Test yang menunggu sleep(5) atau mengasumsikan job selesai dalam waktu tetap sangat rentan. Lebih baik:

  • Gunakan polling dengan timeout yang jelas
  • Expose event/status yang bisa diverifikasi
  • Kontrol clock jika framework mendukung

2. Bergantung pada urutan eksekusi

Test yang lolos hanya jika dijalankan setelah test lain menandakan state bocor. Solusinya:

  • Reset database atau gunakan data terisolasi
  • Jangan berbagi fixture mutable
  • Pastikan queue, cache, dan filesystem dibersihkan

3. Assertion terlalu longgar atau terlalu rapuh

Dua ekstrem yang sama-sama buruk:

  • Terlalu longgar: hanya memeriksa status 200
  • Terlalu rapuh: membandingkan seluruh payload termasuk field yang tidak relevan atau berubah-ubah

Verifikasilah kontrak inti yang benar-benar dijamin.

4. Mock berlebihan pada boundary penting

Jika tujuan test adalah membuktikan integrasi database atau API internal, mem-mock semuanya justru membuat test menipu. Mock cocok untuk mengisolasi aturan lokal, bukan menggantikan bukti integrasi yang benar-benar ingin dijamin.

5. Shared environment yang tidak stabil

Flaky test sering berasal dari environment bersama yang datanya berubah-ubah. Lebih aman memakai environment ephemeral atau resource yang diisolasi per run CI jika memungkinkan.

Contoh workflow verifikasi di CI/CD

Workflow yang sehat memisahkan umpan balik cepat dari verifikasi yang lebih mahal.

Tahap 1: Pre-merge

  • Linting dan static analysis
  • Unit tests
  • Integration tests yang cepat
  • Contract tests untuk perubahan API/event

Tujuannya adalah menangkap mayoritas regresi sebelum merge dengan durasi yang masih masuk akal.

Tahap 2: Build artifact yang immutable

Setelah lolos, buat artifact yang sama untuk dipromosikan ke staging/production. Ini penting agar yang diverifikasi adalah paket yang benar-benar akan dirilis.

Tahap 3: Verifikasi di staging

  • Smoke tests
  • E2E terbatas untuk critical path
  • Integration test tambahan yang bergantung pada environment mirip produksi
  • Pemeriksaan migration dan compatibility

Jika ada dependency eksternal yang tidak bisa disalin ke staging secara penuh, dokumentasikan bagian jaminan yang memang tidak dapat dibuktikan otomatis, lalu tambahkan mitigasi operasional.

Tahap 4: Deploy production dengan guardrail

  • Canary atau rollout bertahap jika memungkinkan
  • Post-deploy smoke tests
  • Monitoring error rate, latency, dan business KPI kunci
  • Rollback atau stop rollout jika sinyal menyimpang

CI/CD bukan hanya tempat menjalankan test, tetapi tempat menghubungkan jaminan software dengan keputusan rilis.

Checklist regression prevention sebelum rilis

  1. Daftar jaminan utama ditulis jelas
    Minimal untuk correctness, invariants, compatibility, failure handling, dan observability.
  2. Setiap jaminan punya owner dan test utama
    Siapa yang bertanggung jawab jika jaminan itu gagal, dan test mana yang membuktikannya.
  3. Perubahan API/event diperiksa kompatibilitasnya
    Jangan hanya mengandalkan review manual diff.
  4. Invariant penting juga ditegakkan di lapisan penyimpanan jika perlu
    Misalnya unique constraint, foreign key, atau transactional boundary.
  5. Skenario gagal utama diuji
    Timeout, retry, duplicate delivery, dependency unavailable, partial failure.
  6. Sinyal observability tersedia
    Log terstruktur, metrik inti, correlation ID, dashboard atau alert minimum.
  7. Smoke test pasca-deploy siap dijalankan otomatis
  8. Test flaky diidentifikasi dan ditangani, bukan diabaikan
    Test yang sering di-retry tanpa akar masalah akan mengikis kepercayaan tim.
  9. Scope test tidak duplikatif tanpa alasan
    Pilih level verifikasi termurah yang memadai.
  10. Known gaps didokumentasikan
    Lebih baik jujur tentang jaminan yang belum bisa dibuktikan daripada merasa aman secara palsu.

Kesalahan umum saat mendefinisikan jaminan software

Menganggap coverage tinggi berarti jaminan tinggi

Coverage hanya memberi tahu bagian kode mana yang tersentuh, bukan apakah perilaku penting benar-benar terbukti.

Mencampur jaminan bisnis dan detail implementasi

Jaminan yang baik berbunyi seperti perilaku sistem, bukan seperti struktur kode. “DiscountService memanggil helper X” bukan jaminan yang bernilai bagi produk.

Tidak membedakan “tidak rusak” dan “mudah didiagnosis saat rusak”

Correctness dan observability adalah dua jaminan berbeda. Banyak insiden menjadi mahal karena sistem gagal diam-diam.

Semua risiko dipaksa ke E2E

E2E itu penting, tetapi mahal. Jika semua hal dibuktikan lewat E2E, pipeline akan lambat dan sinyal gagal sulit ditafsirkan.

Penutup

Mendefinisikan jaminan software dengan test strategy yang jelas berarti berpindah dari pola pikir “menulis test sebanyak mungkin” ke pola pikir “membuktikan klaim yang penting dengan cara yang paling efektif”. Tim yang matang tidak hanya bertanya apakah software mereka punya test, tetapi jaminan apa yang benar-benar didukung oleh test tersebut.

Mulailah dengan daftar jaminan yang eksplisit: correctness, invariants, backward compatibility, failure handling, dan observability. Setelah itu, pilih jenis test yang paling sesuai untuk masing-masing jaminan. Dengan cara ini, test suite menjadi lebih cepat, lebih relevan, dan jauh lebih berguna untuk mencegah regresi nyata.