Flaky test di CI adalah pengujian yang kadang berhasil dan kadang gagal tanpa perubahan pada kode yang diuji. Masalah ini tidak cukup ditangani dengan menekan tombol rerun: tim perlu mengumpulkan bukti, membuat kegagalan dapat direproduksi, lalu mengisolasi sumber nondeterminisme seperti waktu, randomness, urutan eksekusi, concurrency, jaringan, atau data bersama.

Tujuan diagnosis bukan sekadar membuat satu job berwarna hijau, melainkan menemukan kondisi yang memicu kegagalan dan menambahkan pencegahan agar masalah tidak kembali. Retry dapat dipakai sebagai mekanisme mitigasi sementara, tetapi bukan solusi akar masalah.

Mengenali ciri flaky test

Flaky test biasanya memiliki pola yang berbeda dari kegagalan deterministik. Test gagal pada eksekusi tertentu, kemudian berhasil ketika dijalankan ulang dengan commit dan input yang sama. Kegagalan juga sering berkorelasi dengan runner yang lebih lambat, beban CPU tinggi, urutan suite, zona waktu, atau eksekusi paralel.

  • Job gagal pada test atau assertion yang berbeda-beda meskipun commit sama.
  • Rerun tanpa perubahan kode membuat job berhasil.
  • Kegagalan hanya muncul pada CI, tetapi sulit direproduksi di laptop.
  • Pesan error mengarah ke timeout, race condition, port yang sedang dipakai, urutan data, atau tanggal yang berubah.
  • Failure rate meningkat ketika test dijalankan paralel atau ketika jumlah worker berubah.

Bedakan flaky test dari kegagalan deterministik yang hanya terjadi pada lingkungan tertentu. Misalnya, test yang selalu gagal pada database kosong mungkin merupakan bug konfigurasi, bukan flaky test. Bukti dari beberapa eksekusi diperlukan sebelum memberi label flaky.

Mengumpulkan bukti dari histori job CI

Mulailah dari histori pipeline, bukan dari dugaan. Catat commit, branch, runner, versi runtime, jumlah worker, durasi job, test yang gagal, pesan error lengkap, dan apakah rerun berhasil. Data ini membantu membedakan bug aplikasi dari variasi lingkungan.

Data minimum yang perlu dicatat

  • Identitas build: commit, pull request, workflow, job, dan nomor percobaan.
  • Lingkungan: sistem operasi, versi bahasa pemrograman, dependency lockfile, database, browser, dan image container.
  • Konfigurasi test: seed, timezone, locale, mode paralel, jumlah worker, timeout, dan filter test.
  • Konteks kegagalan: nama test, stack trace, request atau query terkait, state fixture, serta log sebelum assertion gagal.
  • Hasil percobaan ulang: apakah test gagal pada test yang sama, dengan seed yang sama, atau hanya setelah urutan tertentu.

Jangan hanya menyimpan pesan assertion failed. Pesan tersebut sering merupakan gejala akhir. Simpan juga nilai input penting, ID entitas, waktu efektif, seed, urutan test, dan informasi worker. Hindari mencatat token, password, atau data pribadi; gunakan redaksi dan identifier yang aman.

Membuat ringkasan failure rate

Untuk setiap test yang dicurigai, buat tabel sederhana berisi jumlah eksekusi, jumlah gagal, jenis kegagalan, serta hasil rerun. Tidak diperlukan angka ambang universal. Yang penting adalah pola yang konsisten dan dapat diaudit. Pisahkan kegagalan akibat infrastructure outage dari kegagalan assertion agar statistik tidak menyesatkan.

Mereproduksi kegagalan secara deterministik

Reproduksi deterministik berarti menjalankan test dengan kondisi yang sama: commit, dependency, seed, waktu, timezone, data, dan tingkat paralelisme. Dengan begitu, tim dapat mengubah satu variabel pada satu waktu dan mengamati dampaknya.

Mengunci seed dan konfigurasi lingkungan

Randomness sebaiknya dikendalikan dari satu sumber konfigurasi. Test runner atau library property-based testing biasanya memiliki mekanisme seed sendiri; gunakan mekanisme resmi tersebut jika tersedia. Jangan hanya menetapkan seed pada generator aplikasi apabila runner juga mengacak urutan test.

#!/usr/bin/env bash
set -euo pipefail

export TEST_SEED="${TEST_SEED:-381742}"
export TZ="UTC"
export LC_ALL="C"
export TEST_WORKERS="1"

printf 'seed=%s tz=%s workers=%s\n' "$TEST_SEED" "$TZ" "$TEST_WORKERS"

# Ganti perintah berikut dengan opsi seed dari test runner yang digunakan.
./run-tests --seed "$TEST_SEED" --workers "$TEST_WORKERS" tests/order.test

Seed yang dicetak ke log harus dapat disalin untuk menjalankan ulang test. Jika runner menggunakan seed untuk mengacak urutan test, simpan juga urutan aktual atau daftar test yang dijalankan. Seed tanpa informasi versi dan konfigurasi belum tentu cukup untuk reproduksi lintas mesin.

Mengisolasi satu test dan mengulanginya

Jalankan test yang gagal secara serial terlebih dahulu, lalu ulangi beberapa kali dengan seed yang sama. Setelah itu, ulangi dengan seed berbeda. Interpretasinya berguna:

  • Gagal pada seed yang sama: kemungkinan ada input acak atau kondisi yang dapat direproduksi.
  • Gagal hanya saat serial: kemungkinan race condition atau kontensi concurrency.
  • Gagal hanya saat paralel: periksa data bersama, port, file temporer, cache, dan database.
  • Gagal pada urutan suite tertentu: cari state global yang tidak dibersihkan.
#!/usr/bin/env bash
set -u

seed=381742
failures=0
for attempt in $(seq 1 50); do
  echo "attempt=$attempt seed=$seed"
  if ! ./run-tests --seed "$seed" --workers 1 tests/order.test; then
    failures=$((failures + 1))
  fi
done

echo "failures=$failures"
test "$failures" -gt 0

Loop seperti ini sebaiknya dijalankan pada reproducer khusus atau job diagnosis, bukan selalu pada pipeline utama. Simpan output setiap percobaan dan jangan mengubah seed, waktu, atau data secara diam-diam di tengah eksperimen.

Mengendalikan waktu dengan fake clock

Pemanggilan langsung ke jam sistem membuat test rentan terhadap pergantian hari, perbedaan timezone, presisi milidetik, dan pekerjaan yang berjalan tepat di batas timeout. Abstraksikan sumber waktu pada kode produksi, lalu gunakan fake clock pada test.

class Clock {
  now() {
    return new Date();
  }
}

function isExpired(expiresAt, clock) {
  return clock.now().getTime() >= expiresAt.getTime();
}

const fixedClock = {
  now: () => new Date('2025-01-15T10:00:00.000Z')
};

// Test memeriksa kondisi yang sama setiap kali dijalankan.
expect(isExpired(new Date('2025-01-15T10:00:01.000Z'), fixedClock)).toBe(false);

Gunakan satu representasi waktu yang jelas, biasanya UTC pada boundary aplikasi, dan uji timezone secara eksplisit jika perilaku lokal memang penting. Fake clock juga perlu memodelkan kemajuan waktu ketika kode menunggu timer; jangan mencampur jam palsu dengan timer nyata tanpa alasan.

Mengisolasi sumber nondeterminisme

Randomness dan urutan eksekusi

Pastikan generator acak menerima seed dan seed tersebut dicatat. Untuk test yang menguji algoritme acak, simpan input yang menyebabkan kegagalan sebagai regression case. Hindari assertion terhadap urutan data jika kontrak aplikasi memang tidak menjamin urutan; gunakan perbandingan sebagai himpunan atau urutkan data berdasarkan kunci yang stabil.

State global, singleton, cache, environment variable, dan mock juga harus dikembalikan setelah test. Fixture yang hanya menambahkan data tanpa cleanup dapat membuat test berikutnya bergantung pada test sebelumnya. Jalankan suite dalam urutan berbeda untuk mendeteksi coupling, lalu perbaiki isolation-nya, bukan sekadar mengunci urutannya.

Concurrency dan data bersama

Race condition dapat muncul ketika dua worker menulis baris database, file, port, cache key, atau resource cloud yang sama. Gunakan identifier unik per test atau per worker, namespace sementara, transaksi yang benar-benar di-rollback, dan resource lifecycle yang eksplisit.

Jangan mengandalkan sleep untuk menunggu proses asynchronous. Tunggu kondisi yang dapat diamati, misalnya event diterima, status berubah, atau queue menjadi idle, dengan timeout yang masuk akal. Sleep memperbesar durasi tetapi tidak menjamin kondisi sudah siap.

Jaringan dan layanan eksternal

Test unit sebaiknya tidak bergantung pada internet atau layanan eksternal. Untuk integration test, gunakan stub atau fake server yang mengontrol response, delay, timeout, dan error. Jika dependency eksternal memang harus dipanggil, catat endpoint logis, status response, correlation ID, dan timeout tanpa membocorkan credential.

Pastikan test tidak bergantung pada urutan response jaringan. Kode produksi harus menangani retry, timeout, dan cancellation secara sadar. Pada test, bedakan kegagalan kontrak aplikasi dari gangguan jaringan CI agar keduanya tidak diberi tindakan yang sama.

Logging konteks kegagalan tanpa membuat log bising

Logging yang baik menjawab apa yang diuji, dengan input apa, pada kondisi apa, dan resource mana yang dipakai. Tambahkan konteks terstruktur pada boundary penting, seperti awal test, pembuatan fixture, request keluar, perubahan state, dan assertion utama.

logger.info('test_context', {
  test: 'order expires after deadline',
  seed: process.env.TEST_SEED,
  timezone: process.env.TZ,
  worker: process.env.TEST_WORKER,
  orderId: order.id,
  effectiveNow: clock.now().toISOString()
});

Gunakan correlation ID agar log dari beberapa worker tidak tercampur. Ambil screenshot, trace, atau dump database hanya ketika relevan dan dengan redaksi data sensitif. Log harus membantu reproduksi; menambahkan seluruh payload atau seluruh environment sering justru menyulitkan pencarian sinyal.

Retry yang aman dan quarantine yang terukur

Memakai retry tanpa menyamarkan bug

Retry dapat menjaga pipeline tetap informatif ketika ada gangguan sementara pada infrastructure, tetapi retry tidak memperbaiki race condition, cleanup yang salah, atau assertion yang tidak deterministik. Jika retry digunakan, simpan hasil percobaan pertama dan tandai job sebagai flaky atau tidak stabil, bukan hanya menampilkan hasil percobaan terakhir.

Retry hanya aman bila operasi test idempotent atau setiap percobaan mendapat lingkungan baru. Hindari retry untuk test yang mungkin sudah mengirim email, melakukan pembayaran, mengubah data produksi, atau memicu efek eksternal yang tidak dapat dibatalkan. Pada integration test, gunakan sandbox, transaction isolation, atau resource unik per percobaan.

Kapan test perlu di-quarantine?

Quarantine layak dipertimbangkan sebagai mitigasi sementara apabila test berulang kali mengganggu delivery, penyebabnya sedang diselidiki, dan ada issue owner serta batas waktu yang jelas. Quarantine bukan berarti menghapus test dari radar. Test tetap harus dijalankan di job terpisah atau scheduled job dengan hasil yang dapat dilacak.

  • Beri label atau daftar quarantine yang memiliki owner, tanggal mulai, issue, dan alasan.
  • Simpan bukti failure rate dan konfigurasi reproduksi.
  • Tentukan target perbaikan dan notifikasi jika test kembali gagal.
  • Jangan memasukkan quarantine baru tanpa persetujuan atau aturan yang konsisten.

Kriteria menghapus quarantine

Hapus quarantine setelah akar masalah diperbaiki, test dijalankan pada konfigurasi normal, dan hasilnya stabil pada pengulangan yang relevan. Verifikasi juga bahwa regression test yang baru mencakup kondisi pemicu sebelumnya. Jika test dihapus atau diubah karena kontraknya keliru, dokumentasikan keputusan tersebut agar celah coverage tetap diketahui.

Checklist pencegahan regresi

  • Catat seed, timezone, locale, worker, versi runtime, dan dependency pada artefak CI.
  • Gunakan fake clock dan abstraksi waktu untuk logika yang bergantung pada waktu.
  • Isolasi random generator dan simpan input kegagalan sebagai regression case.
  • Jangan bergantung pada urutan test, urutan query, atau urutan response yang tidak dijamin.
  • Buat fixture, file temporer, port, cache, dan database namespace yang unik.
  • Ganti sleep dengan penantian berbasis kondisi dan timeout eksplisit.
  • Mock atau stub layanan eksternal pada unit test dan kontrol dependency pada integration test.
  • Simpan konteks kegagalan yang cukup, tetapi redaksi credential dan data sensitif.
  • Jika memakai retry, pertahankan hasil percobaan pertama dan pastikan operasi idempotent.
  • Review daftar quarantine secara berkala dan hapus hanya setelah kriteria stabilitas terpenuhi.

Flaky test menjadi lebih mudah diperbaiki ketika CI diperlakukan sebagai sumber bukti, bukan sekadar lampu merah atau hijau. Kunci pendekatannya adalah mengunci kondisi eksekusi, mengubah satu variabel pada satu waktu, dan memperbaiki sumber nondeterminisme di level kode maupun lingkungan.