Flaky integration test di CI adalah test yang kadang lolos, kadang gagal, padahal kode tidak berubah. Solusi yang benar bukan langsung menambah retry global, menonaktifkan test, atau memindahkan semuanya ke suite yang jarang dijalankan. Pendekatan yang lebih aman adalah mengidentifikasi sumber nondeterminism, memperbaiki isolasi, menata pipeline, dan mengukur flaky rate secara eksplisit.

Jika target Anda adalah stabilitas tanpa menurunkan coverage, fokus utamanya ada pada tiga hal: determinisme test, observability saat test berjalan, dan desain pipeline yang memisahkan sinyal cepat dari validasi penuh. Dengan begitu, integration test tetap memberi perlindungan nyata terhadap regresi, tetapi tidak menjadi sumber false alarm yang menghambat delivery.

Mengapa integration test sering flaky di CI

Integration test berada di area yang paling mudah terkena ketidakpastian: database, queue, cache, network, waktu, proses paralel, dan state aplikasi yang tersebar. Di laptop developer, test bisa terlihat stabil karena beban rendah, urutan eksekusi kebetulan konsisten, dan resource lebih terprediksi. Di CI, kondisi ini berubah drastis.

1. Race condition

Race condition terjadi ketika hasil test bergantung pada urutan atau timing antar proses, thread, worker, job, atau callback async. Contoh umum:

  • Test mengasumsikan event sudah diproses padahal worker belum selesai.
  • Dua test paralel menulis ke resource yang sama.
  • Cleanup berjalan sebelum proses async benar-benar berhenti.

Masalah ini sering tampak sebagai error yang sulit direproduksi, seperti data belum ada, status belum berubah, atau port/resource masih dipakai.

2. Ketergantungan waktu

Test yang memakai waktu nyata cenderung rapuh. Contohnya:

  • Mengandalkan sleep dengan durasi tetap.
  • Membandingkan timestamp dengan toleransi terlalu ketat.
  • Menjalankan logic yang sensitif ke timezone, DST, atau jam sistem.

Jika CI lebih lambat atau lebih sibuk daripada lingkungan lokal, asumsi timing yang tampak aman bisa runtuh.

3. Data bersama antar test

Shared state adalah penyebab klasik flaky test. Sumbernya bisa berupa:

  • Record database yang dipakai ulang.
  • Bucket, file, cache key, atau queue name yang sama.
  • User, tenant, atau identifier yang tidak unik per run.

Begitu test berjalan paralel atau rerun parsial, data saling bertabrakan dan hasil menjadi tidak konsisten.

4. Network dependency

Integration test yang memanggil layanan eksternal menghadapi latensi, timeout, rate limit, gangguan DNS, atau perubahan data dari pihak ketiga. Bahkan jika API eksternal stabil, jaringan CI tetap bisa menambah sumber ketidakpastian.

5. Async cleanup yang tidak tuntas

Banyak suite terlihat bersih di akhir test, tetapi sebenarnya masih meninggalkan job, koneksi, file temporary, message, atau record tertunda. Test berikutnya lalu mewarisi sisa state tersebut dan gagal secara acak.

6. Environment tidak deterministik

CI sering berjalan pada container atau VM yang berbagi CPU, I/O, dan network. Variasi kecil pada scheduling, locale, timezone, urutan file, random seed, atau ketersediaan service pendukung dapat mengubah hasil test. Semakin banyak asumsi implisit pada test, semakin tinggi peluang flaky.

Prinsip utama: stabilkan sumber masalah, bukan hanya gejalanya

Kesalahan umum adalah memperlakukan flaky test sebagai masalah kecil yang cukup ditutup dengan retry. Retry memang bisa membantu untuk kegagalan transient tertentu, tetapi jika dipakai tanpa diagnosis, ia hanya menyamarkan defect desain pada test atau sistem.

Prinsip yang lebih aman:

  • Perbaiki determinisme sebelum menambah retry.
  • Isolasi state per test atau per run.
  • Hilangkan ketergantungan pada waktu nyata bila memungkinkan.
  • Batasi dependency eksternal hanya pada skenario yang memang ingin divalidasi.
  • Kumpulkan bukti saat test gagal, bukan hanya status pass/fail.

Jika sebuah test gagal karena bug nyata di aplikasi, itu sinyal yang harus dipertahankan. Jika ia gagal karena nondeterminism, prioritasnya adalah memperbaiki desain test atau kontrak integrasinya, bukan menghapus validasinya.

Workflow diagnosis flaky integration test di CI

Tanpa workflow diagnosis yang disiplin, tim mudah terjebak pada tebakan. Berikut alur yang praktis dan bisa diterapkan lintas stack.

Langkah 1: Bedakan failure deterministik vs flaky

Pertama, pastikan apakah test memang intermittent. Jalankan ulang test yang sama pada commit yang sama beberapa kali, idealnya di lingkungan CI yang mirip dengan eksekusi normal. Jika hasilnya berubah-ubah tanpa perubahan kode, Anda memang berurusan dengan flaky test.

Langkah 2: Kumpulkan konteks saat gagal

Jangan berhenti pada stack trace. Simpan artefak yang membantu rekonstruksi kondisi saat gagal:

  • Log aplikasi dan log test.
  • Request/response penting antar service.
  • State database yang relevan.
  • Daftar job/message yang masih pending.
  • Waktu mulai/selesai tiap langkah test.
  • Resource identifier seperti tenant id, order id, queue name, file path, dan run id.

Sering kali flaky test menjadi jelas setelah terlihat bahwa test membaca state sebelum write benar-benar commit, atau memakai data yang ternyata sudah dipakai test lain.

Langkah 3: Reproduksi dengan penyederhanaan bertahap

Kurangi variabel satu per satu:

  1. Jalankan test secara terpisah.
  2. Nonaktifkan paralelisme sementara.
  3. Ganti dependency eksternal dengan stub atau sandbox yang stabil.
  4. Paksa timezone, locale, dan random seed.
  5. Aktifkan log lebih detail hanya untuk test yang dicurigai.

Tujuan tahap ini bukan mencari workaround permanen, tetapi menemukan komponen mana yang memperkenalkan nondeterminism.

Langkah 4: Kelompokkan akar masalah

Setelah ada bukti, klasifikasikan penyebabnya: timing, shared state, network, cleanup, atau environment. Pengelompokan ini penting karena perbaikannya berbeda. Menambah timeout mungkin membantu kasus latensi sesaat, tetapi tidak akan menyelesaikan benturan data antar test.

Langkah 5: Terapkan perbaikan terkecil yang menghilangkan nondeterminism

Jangan langsung mendesain ulang seluruh pipeline. Mulailah dari perubahan yang paling dekat dengan akar masalah: identifier unik, transaksi per test, fake clock, wait berbasis kondisi, atau penggantian API eksternal menjadi dependency yang dapat dikontrol.

Teknik inti untuk menstabilkan flaky integration test di CI

1. Isolasi test dan resource secara ketat

Setiap test harus punya ruang kerja sendiri. Ini berlaku untuk database, cache, file, dan message bus.

Praktik yang efektif:

  • Gunakan identifier unik per test run, misalnya prefix/suffix pada tenant, order, file, atau key cache.
  • Hindari data tetap seperti test@example.com atau order-123.
  • Jika memakai database nyata, reset state secara konsisten: transaksi rollback, schema terpisah, atau database ephemeral.
  • Pastikan queue/topic/subscription yang dipakai test tidak berbagi state dengan run lain.

Contoh pola identifier unik:

RUN_ID = env("CI_RUN_ID") || timestamp() || randomUUID()
TEST_PREFIX = "it-" + RUN_ID

customerEmail = TEST_PREFIX + "-user@example.test"
orderId = TEST_PREFIX + "-order-001"
cacheKey = TEST_PREFIX + ":invoice:" + orderId

Mengapa ini efektif? Karena sebagian besar flaky test paralel berasal dari tabrakan state yang sebenarnya tidak terlihat di level kode test. Identifier unik membuat resource dapat dilacak dan dibersihkan dengan aman.

2. Ganti sleep tetap dengan wait berbasis kondisi

Sleep 2 detik tampak sederhana, tetapi sering menjadi akar flaky test: terlalu pendek di CI, terlalu lama di lokal, dan tetap tidak menjamin kondisi yang benar. Lebih baik menunggu kondisi bisnis yang memang dibutuhkan.

deadline = now() + 10s
while (now() < deadline) {
  status = fetchOrderStatus(orderId)
  if (status == "processed") {
    return success
  }
  wait(200ms)
}
fail("order tidak mencapai status processed dalam batas waktu")

Pola ini lebih stabil karena test memeriksa keadaan yang relevan, bukan menebak durasi. Namun, tetap batasi timeout agar kegagalan nyata tidak tersembunyi terlalu lama.

3. Gunakan fake clock untuk logic berbasis waktu

Jika aplikasi memiliki retry internal, expiry, scheduler, atau perilaku yang dipicu waktu, usahakan sumber waktu bisa diinjeksikan. Dengan begitu, test tidak bergantung pada jam sistem.

Contoh desain umum:

  • Abstraksikan akses waktu di aplikasi, misalnya melalui clock provider.
  • Di production, provider membaca waktu sistem.
  • Di test, provider dapat dikunci atau digeser maju sesuai skenario.
clock.set("2025-01-01T10:00:00Z")
createSession(userId)
clock.advance(minutes=31)
result = validateSession(userId)
assert result == "expired"

Ini menghilangkan kebutuhan menunggu waktu nyata dan membuat skenario expiry lebih deterministik. Trade-off-nya, desain aplikasi harus memberi titik injeksi waktu, yang mungkin perlu refactor kecil.

4. Hindari network dependency yang tidak bisa dikendalikan

Untuk sebagian besar integration test di CI, layanan pihak ketiga sebaiknya tidak dipanggil langsung kecuali tujuannya memang menguji integrasi end-to-end terhadap layanan tersebut. Pilihan yang lebih stabil:

  • Contract test untuk memvalidasi format request/response.
  • Stub/mock service yang berjalan lokal di pipeline.
  • Sandbox yang datanya bisa dikontrol dan tidak berubah liar.

Gunakan panggilan ke sistem eksternal nyata secara selektif, misalnya pada smoke suite atau regression suite terjadwal. Ini menjaga coverage tetap ada, tetapi dipisah dari pipeline yang membutuhkan sinyal cepat dan stabil.

5. Pastikan async cleanup benar-benar selesai

Cleanup yang hanya memicu penghapusan tanpa menunggu hasilnya sering menimbulkan kebocoran state. Pastikan teardown memverifikasi bahwa resource sudah benar-benar hilang atau worker sudah benar-benar berhenti.

  • Jangan anggap delete requested sama dengan deleted.
  • Tunggu queue kosong jika test bergantung pada drain queue.
  • Tutup koneksi, file handle, dan proses background secara eksplisit.
  • Tambahkan validasi akhir bahwa resource test run tidak tersisa.

6. Kendalikan environment agar lebih deterministik

Beberapa hal sederhana sering membantu besar:

  • Paksa timezone dan locale yang sama di semua runner.
  • Tetapkan random seed jika test memakai data acak.
  • Kurangi sumber paralelisme untuk test yang sensitif.
  • Pastikan urutan input tidak bergantung pada hasil sort yang tidak stabil.
  • Gunakan image/container CI yang konsisten.

Tujuannya bukan membuat lingkungan sempurna, melainkan mengurangi variasi yang tidak relevan terhadap logika bisnis yang diuji.

Retry yang aman: kapan dipakai dan bagaimana membatasinya

Retry bukan selalu buruk. Ia masuk akal untuk kegagalan transient yang memang merupakan bagian dari sistem terdistribusi, misalnya startup dependency yang belum siap atau timeout jaringan internal sesaat. Namun, retry harus terukur, sempit, dan dapat diaudit.

Aturan retry yang lebih aman

  • Terapkan retry pada test tertentu, bukan seluruh suite secara membabi buta.
  • Catat bahwa test lolos setelah retry; jangan samakan dengan pass bersih.
  • Batasi jumlah retry rendah agar bug nyata tidak tertutup.
  • Gunakan retry hanya setelah ada hipotesis teknis mengapa kegagalan bersifat transient.
  • Buat backlog untuk menghapus retry sementara setelah akar masalah diperbaiki.

Retry yang aman adalah alat mitigasi sementara, bukan status akhir. Jika suatu test terus bergantung pada retry, artinya desain test atau sistem masih perlu dibenahi.

Observability pada test: buat kegagalan mudah dijelaskan

Banyak tim punya observability yang baik di production, tetapi minim di test. Padahal flaky test sulit diperbaiki tanpa jejak eksekusi yang cukup.

Apa yang sebaiknya direkam

  • Correlation ID / Run ID untuk setiap test dan setiap request turunannya.
  • Log terstruktur dengan field penting: test_name, run_id, resource_id, attempt, duration_ms.
  • Event penting: create, update, publish, consume, retry, cleanup.
  • Snapshot state saat gagal: record database terkait, message pending, response body, dan error code.
  • Durasi langkah-langkah utama agar bottleneck timing mudah terlihat.
{
  "test_name": "payment should settle after capture",
  "run_id": "it-20250410-183001-7f2a",
  "attempt": 1,
  "step": "wait_settlement",
  "order_id": "it-20250410-183001-7f2a-order-001",
  "duration_ms": 842,
  "status": "timeout"
}

Dengan data seperti ini, Anda dapat menjawab pertanyaan penting: apakah test gagal karena status tidak pernah berubah, karena salah membaca resource, atau karena cleanup test sebelumnya belum selesai.

Tagging suite: cepat, stabil, dan tetap menjaga coverage

Tidak semua integration test harus diperlakukan sama di pipeline utama. Pembagian suite yang jelas membantu menjaga reliabilitas tanpa memangkas coverage.

Pembagian yang umum dan masuk akal

  • PR / merge suite: test yang harus cepat, stabil, dan relevan untuk perubahan umum.
  • Smoke suite: subset penting untuk memastikan jalur kritis aplikasi hidup pada environment target.
  • Regression suite: cakupan lebih luas, bisa lebih lambat, dijalankan terjadwal atau sebelum rilis.

Intinya bukan memindahkan test “yang bermasalah” secara sembarangan, melainkan mengelompokkan berdasarkan biaya eksekusi, tingkat determinisme, dan tujuan validasi.

Kapan test boleh dipindah ke smoke atau regression suite

Pindahkan test jika memenuhi salah satu kondisi berikut:

  • Ia memerlukan dependency eksternal nyata yang tidak bisa distub dengan representatif.
  • Ia memvalidasi alur lintas sistem yang secara inheren lambat atau mahal.
  • Ia lebih cocok sebagai verifikasi lingkungan deploy daripada guardrail setiap commit.
  • Coverage logikanya sudah terwakili pada level lebih cepat, dan test ini fokus pada validasi jalur kritis end-to-end.

Namun, jangan gunakan tagging sebagai alasan untuk menyembunyikan flaky test dari pipeline utama. Jika test tetap relevan untuk perubahan harian, akar flakiness tetap harus diperbaiki.

Desain pipeline CI yang lebih tahan flaky test

Pipeline yang baik memberi umpan balik cepat sekaligus menjaga validasi menyeluruh. Salah satu pola yang umum dipakai:

Tahap 1: Fast deterministic checks

  • Lint, static analysis, unit test.
  • Integration test yang sudah terisolasi dan minim dependency eksternal.
  • Target: sinyal cepat untuk developer.

Tahap 2: Core integration suite

  • Menjalankan integration test yang memakai database, cache, queue, dan service internal.
  • Boleh paralel, tetapi hanya jika state benar-benar terisolasi.
  • Artefak observability disimpan untuk setiap kegagalan.

Tahap 3: Smoke/regression

  • Jalur bisnis kritis dengan dependency yang lebih realistis.
  • Dijalankan setelah merge, terjadwal, atau sebelum rilis.
  • Dapat memakai sandbox atau environment khusus.

Contoh struktur konseptual pipeline:

stages:
  - validate
  - core_integration
  - smoke
  - regression

validate:
  runs: lint, unit, deterministic integration

core_integration:
  runs: isolated DB/cache/queue integration tests
  on_failure: upload logs, traces, DB snapshot metadata

smoke:
  runs: critical path with realistic dependencies
  trigger: post-merge or deploy candidate

regression:
  runs: broad end-to-end scenarios
  trigger: nightly or pre-release

Struktur seperti ini membantu menjaga coverage tetap luas tanpa memaksa seluruh test mahal berjalan di jalur kritis setiap commit.

Metrik yang perlu dipantau: jangan hanya pass rate

Pass rate saja tidak cukup karena test yang lolos setelah retry bisa terlihat “hijau”, padahal kualitas sinyalnya buruk. Tambahkan metrik berikut:

Flaky rate per test

Definisi praktis: proporsi run pada commit atau baseline yang sama yang menunjukkan hasil berbeda. Misalnya, test yang kadang pass dan kadang fail pada kondisi yang setara harus ditandai sebagai flaky, walaupun rerun terakhir hijau.

Retry pass rate

Berapa banyak test yang gagal pada percobaan pertama lalu lolos pada retry. Ini indikator penting bahwa pipeline terlihat sehat tetapi sebenarnya menyimpan instabilitas.

MTTR flaky test

Berapa lama rata-rata test flaky dibiarkan sebelum diperbaiki. Jika angka ini tinggi, backlog kualitas test biasanya sedang menumpuk.

Top offenders

Urutkan test berdasarkan frekuensi intermittent failure, durasi, dan dampaknya terhadap pipeline. Prioritaskan test yang paling sering mengganggu merge atau release.

Contoh format pelaporan yang sederhana:

test_name, total_runs, first_attempt_failures, retry_passes, hard_failures, flaky_rate
payment_settlement_it, 120, 9, 7, 2, 5.8%
user_session_expiry_it, 80, 6, 6, 0, 7.5%
order_export_it, 60, 1, 0, 1, 1.7%

Metrik ini membantu tim mengambil keputusan berbasis data: test mana yang perlu diperbaiki dulu, mana yang cocok dipindah ke suite lain, dan apakah retry benar-benar menolong atau justru menyamarkan masalah.

Checklist investigasi flaky integration test

Gunakan checklist berikut saat menemukan test yang gagal sporadis di CI:

  • Apakah test memakai data unik per run?
  • Apakah ada shared state di database, cache, file, atau queue?
  • Apakah test bergantung pada urutan eksekusi?
  • Apakah ada sleep tetap yang seharusnya diganti wait berbasis kondisi?
  • Apakah logic test sensitif terhadap jam sistem, timezone, atau timestamp presisi tinggi?
  • Apakah dependency eksternal bisa distub, disandbox, atau dipindah ke smoke/regression suite?
  • Apakah cleanup benar-benar menunggu resource hilang?
  • Apakah paralelisme memperkenalkan benturan resource?
  • Apakah log, correlation ID, dan snapshot state cukup untuk menjelaskan kegagalan?
  • Apakah test hanya lolos karena retry?
  • Apakah ada random input tanpa seed yang tetap?
  • Apakah environment CI berbeda signifikan dari asumsi test?

Trade-off kecepatan vs reliabilitas

Menstabilkan integration test hampir selalu melibatkan trade-off. Menambah isolasi, observability, dan cleanup yang lebih ketat bisa membuat pipeline sedikit lebih lambat. Sebaliknya, mengejar kecepatan dengan paralelisme agresif dan dependency nyata yang tidak terkontrol sering menaikkan flaky rate.

Pendekatan yang biasanya paling sehat adalah:

  • Optimalkan reliabilitas dulu pada jalur yang memblokir merge.
  • Pisahkan validasi mahal ke stage yang sesuai, bukan dihapus.
  • Kurangi biaya secara selektif setelah penyebab instabilitas dipahami.

Dengan kata lain, jangan memilih antara coverage dan stabilitas seolah keduanya saling meniadakan. Yang dibutuhkan adalah desain suite dan pipeline yang tepat.

Kesalahan umum yang perlu dihindari

  • Menonaktifkan test tanpa analisis akar masalah.
  • Menambah retry global lalu menganggap masalah selesai.
  • Menggunakan data tetap yang dipakai banyak test.
  • Mengandalkan sleep alih-alih kondisi yang terukur.
  • Memanggil layanan eksternal nyata untuk semua integration test.
  • Tidak menyimpan artefak kegagalan di CI.
  • Menganggap test stabil di lokal berarti aman di CI.

Penutup

Strategi stabilkan flaky integration test di CI tanpa menurunkan coverage pada dasarnya adalah upaya mengembalikan determinisme. Perbaiki isolasi state, hilangkan ketergantungan waktu nyata, batasi dependency eksternal, pastikan cleanup tuntas, dan tambah observability agar kegagalan bisa dijelaskan dengan cepat.

Jika Anda harus memilih prioritas, mulai dari test yang paling sering mengganggu pipeline dan paling dekat dengan jalur bisnis kritis. Dari sana, ukur flaky rate, kurangi retry yang tidak perlu, dan susun pipeline yang memisahkan sinyal cepat dari validasi penuh. Hasil akhirnya bukan sekadar CI yang lebih hijau, tetapi suite integration test yang benar-benar dapat dipercaya.