Mengurangi flaky test di CI dengan isolasi data dan kontrol waktu bukan soal menambah retry sebanyak mungkin. Akar masalahnya biasanya ada pada test yang tidak benar-benar terisolasi: data bocor antar-test, waktu sistem berubah, urutan eksekusi memengaruhi hasil, network call tidak stabil, atau cache masih menyimpan state dari run sebelumnya.

Solusi yang efektif adalah membuat test deterministik: input sama, lingkungan sama, hasil sama. Itu berarti setiap test harus mengontrol data, waktu, randomness, dependensi eksternal, dan state runtime. Jika sebuah test hanya lulus ketika dijalankan sendiri tetapi gagal di CI atau saat dijalankan paralel, hampir pasti ada kebocoran state atau asumsi tersembunyi yang belum dikendalikan.

Mengapa flaky test sering muncul di CI

CI memperbesar masalah yang selama ini tidak terlihat di mesin lokal. Pipeline biasanya menjalankan test secara paralel, pada mesin yang lebih lambat atau lebih cepat, dengan urutan berbeda, dan dalam environment yang lebih bersih. Test yang diam-diam bergantung pada kondisi lokal akan mulai gagal secara acak.

Beberapa penyebab umum adalah:

  • Data bersama yang bocor antar-test, misalnya record database, file temporary, atau state global yang tidak dibersihkan.
  • Ketergantungan terhadap waktu, seperti penggunaan waktu sistem langsung, timeout yang terlalu ketat, atau perhitungan tanggal yang sensitif zona waktu.
  • Randomness tanpa seed, sehingga test menghasilkan input berbeda tiap run dan kadang memicu edge case yang tidak dipahami.
  • Urutan eksekusi, ketika test A diam-diam menyiapkan kondisi untuk test B.
  • Network call ke API eksternal yang lambat, rate-limited, atau tidak stabil.
  • Cache dan state proses, seperti cache in-memory, singleton, atau konfigurasi yang dimodifikasi lalu tidak dikembalikan.

Prinsip utamanya sederhana: jika hasil test bergantung pada hal di luar kontrol test itu sendiri, test tersebut berisiko flaky.

Strategi inti: buat setiap test benar-benar terisolasi

1. Isolasi data per test

Jangan mengandalkan data yang dibuat oleh test lain. Setiap test sebaiknya membuat data yang ia butuhkan sendiri, lalu membersihkannya, atau dijalankan di dalam transaksi yang di-rollback setelah selesai. Untuk kasus yang tidak cocok dengan transaksi, gunakan database/schema terpisah per suite atau namespace unik per test run.

Praktik yang biasanya efektif:

  • Buat data di awal test dengan factory/fixture, bukan membaca record yang “diasumsikan sudah ada”.
  • Gunakan identifier unik per test run agar tidak bentrok saat paralel.
  • Reset database, storage, dan message queue pada boundary yang jelas: per test, per class, atau per suite, sesuai biaya eksekusi.
  • Hindari state global yang dapat dimutasi dari banyak test.

Contoh pendekatan umum dengan namespace unik:

// pseudocode umum, tidak spesifik framework
const runId = process.env.CI_RUN_ID || generateUniqueId();

function testUserEmail(name) {
  return `${name}.${runId}@example.test`;
}

// Setiap test membuat data sendiri
const user = createUser({ email: testUserEmail('checkout') });
const order = createOrder({ userId: user.id, total: 150000 });

Pendekatan ini membantu ketika cleanup tidak sempurna. Data lama masih ada, tetapi tidak saling mengganggu dengan data dari run lain.

2. Gunakan fixture yang deterministik

Fixture deterministik berarti data test dapat diprediksi dan tidak berubah antar-run. Ini penting untuk mengurangi kegagalan yang sulit direproduksi. Hindari fixture yang bergantung pada waktu sekarang, data acak tanpa seed, atau urutan insert yang tidak dijamin.

Praktik yang aman:

  • Gunakan nilai eksplisit untuk field penting seperti tanggal, status, dan relasi.
  • Jika memakai generator acak, tetapkan seed.
  • Jangan membuat assertion berdasarkan urutan data kecuali query memang mengurutkan secara eksplisit.

Contoh masalah yang sering terjadi:

// Rentan flaky karena urutan tidak dijamin
const items = await repository.findAll();
expect(items[0].status).toBe('active');

// Lebih aman: urutkan eksplisit atau assertion berbasis isi
const items = await repository.findAll({ orderBy: 'created_at ASC' });
expect(items.map(i => i.status)).toContain('active');

3. Bersihkan cache dan state proses

Cache lokal, singleton, static variable, environment variable, dan konfigurasi runtime sering menjadi sumber flaky test yang tersembunyi. Test pertama mengubah state, test berikutnya mewarisi state tersebut.

Yang perlu diperiksa:

  • Cache aplikasi: in-memory, Redis, filesystem, browser storage, service worker cache.
  • State global: singleton container, config registry, feature flag override.
  • Mock yang tidak di-reset antar-test.
  • Clock atau timer palsu yang tetap aktif setelah test selesai.

Biasakan menambahkan langkah reset eksplisit di setup/teardown test suite. Jangan bergantung pada asumsi bahwa runner akan membersihkan semuanya secara otomatis.

Kontrol waktu: sumber flaky yang paling sering diremehkan

Gunakan fake clock, bukan waktu sistem langsung

Test yang memakai waktu sistem nyata mudah gagal karena perbedaan latency, timezone, DST, atau karena waktu bergerak saat assertion dilakukan. Solusi yang lebih andal adalah menyuntikkan clock ke aplikasi atau memakai fake clock pada boundary yang relevan.

Alih-alih memanggil waktu sistem langsung di banyak tempat, gunakan abstraksi sederhana:

// pseudocode umum
class Clock {
  now() {
    return new Date();
  }
}

class FixedClock extends Clock {
  constructor(fixedTime) {
    super();
    this.fixedTime = fixedTime;
  }

  now() {
    return new Date(this.fixedTime);
  }
}

function isExpired(session, clock) {
  return session.expiresAt <= clock.now();
}

const clock = new FixedClock('2025-01-10T10:00:00Z');
expect(isExpired({ expiresAt: '2025-01-10T09:59:59Z' }, clock)).toBe(true);

Mengapa ini bekerja? Karena test tidak lagi bergantung pada waktu nyata saat eksekusi. Anda mengubah variabel yang bergerak menjadi input yang stabil.

Perhatikan timezone dan format tanggal

Bug yang terlihat seperti flaky test sering sebenarnya masalah timezone. Test lulus di laptop dengan timezone lokal tertentu, lalu gagal di CI yang memakai UTC.

Untuk menghindarinya:

  • Standarkan timezone test environment, biasanya UTC.
  • Simpan dan bandingkan timestamp dalam format yang jelas.
  • Hindari assertion berbasis string tanggal yang dipengaruhi locale kecuali memang itu yang diuji.

Jika sebuah test gagal hanya pada jam tertentu atau di awal/akhir hari, curigai logika waktu, timezone, atau boundary tanggal terlebih dahulu.

Hindari assertion dengan timeout terlalu ketat

Test asinkron sering memakai sleep atau timeout arbitrer seperti 100 ms, lalu gagal di CI karena mesin lebih sibuk. Lebih baik gunakan polling dengan batas waktu yang wajar dan kondisi yang jelas, bukan menunggu durasi tetap yang rapuh.

// pseudocode umum
await waitUntil(
  async () => (await getJobStatus(jobId)) === 'done',
  { timeoutMs: 5000, intervalMs: 100 }
);

Ini lebih robust daripada sleep(200) lalu berharap state sudah berubah.

Mengendalikan random seed, urutan eksekusi, dan network call

Seed randomness agar kegagalan bisa direproduksi

Random input kadang berguna untuk memperluas cakupan skenario, tetapi randomness tanpa seed membuat kegagalan sulit diulang. Jika Anda menggunakan data acak, simpan seed dan tampilkan di log CI.

// pseudocode umum
const seed = process.env.TEST_SEED || 'default-seed';
const rng = createSeededRandom(seed);

const amount = rng.intBetween(1, 1000);
console.log('TEST_SEED=', seed);

Dengan begitu, ketika test gagal, Anda bisa menjalankannya ulang dengan seed yang sama untuk mereproduksi kondisi identik.

Deteksi ketergantungan pada urutan test

Test yang hanya lulus dalam urutan tertentu biasanya punya state bocor atau setup yang tidak lengkap. Cara mendeteksinya:

  • Acak urutan eksekusi di CI atau job khusus harian.
  • Jalankan test satu per satu dan paralel untuk membandingkan perilaku.
  • Kelompokkan test yang menyentuh resource sama, lalu cek apakah ada benturan nama, port, atau data.

Jika test A harus dijalankan sebelum test B agar lolos, itu bukan optimasi. Itu tanda desain test yang salah.

Mock atau virtualisasi network call

Network call ke layanan eksternal adalah sumber flaky test klasik. Latency, DNS, sertifikat, rate limit, dan perubahan data eksternal membuat hasil tidak stabil. Untuk test unit dan integration tertentu, lebih baik gunakan mock, stub, atau service virtual yang mengembalikan respons deterministik.

Gunakan network nyata hanya bila yang diuji memang integrasi dengan layanan tersebut, dan tempatkan itu pada kategori test terpisah dengan ekspektasi reliabilitas berbeda.

Praktik yang berguna:

  • Blokir akses internet default pada test biasa agar panggilan eksternal tidak lolos diam-diam.
  • Gunakan fixture respons HTTP yang stabil.
  • Untuk contract test, verifikasi format request/response tanpa tergantung kondisi produksi.

Retry terukur, bukan retry membabi buta

Retry bisa berguna sebagai alat diagnosis atau pelindung sementara, tetapi bukan solusi utama untuk flaky test. Retry yang diterapkan tanpa analisis hanya menyembunyikan masalah dan menurunkan sinyal kualitas dari CI.

Kapan retry masih masuk akal

  • Sementara, sambil root cause sedang diinvestigasi dan ada tiket yang jelas.
  • Pada test yang bergantung pada resource yang memang eventual-consistent, dengan batas retry kecil dan alasan yang terdokumentasi.
  • Untuk mengumpulkan bukti tambahan, misalnya artifact, log, dan seed saat kegagalan pertama terjadi.

Anti-pattern: retry membabi buta

Contoh anti-pattern yang perlu dihindari:

  • Menerapkan retry ke semua test tanpa memilah penyebabnya.
  • Menambah retry setiap kali pipeline merah, tanpa inspeksi log dan reproduksi.
  • Menganggap test “aman” karena akhirnya lulus di percobaan kedua.

Jika kegagalan pertama diabaikan, Anda kehilangan alarm dini untuk bug nyata, race condition, atau regresi performa.

Bedakan flaky test dari bug nyata

Tidak semua test yang kadang gagal adalah flaky. Ada kemungkinan aplikasi memang memiliki race condition atau bug yang muncul hanya pada timing tertentu. Ini justru bug nyata yang berhasil terungkap oleh CI.

Tanda yang mengarah ke bug nyata:

  • Kegagalan berkorelasi dengan beban, paralelisme, atau timing tertentu.
  • Assertion yang gagal konsisten pada area bisnis yang sama.
  • Log menunjukkan state aplikasi invalid, bukan sekadar resource test yang bocor.

Tanda yang lebih mengarah ke flaky test infrastruktur/tes:

  • Kadang gagal pada assertion yang tidak relevan dengan perubahan kode.
  • Lulus saat dijalankan sendiri tetapi gagal saat suite penuh.
  • Gagal hanya di CI dan terkait timeout, urutan, timezone, atau cleanup.

Karantina test yang disiplin

Karantina berguna untuk menjaga pipeline utama tetap bernilai, tetapi harus diperlakukan sebagai pengecualian sementara, bukan tempat pembuangan permanen.

Aturan karantina yang sehat:

  • Test yang dikarantina harus diberi label jelas dan link ke tiket investigasi.
  • Tetapkan SLA internal untuk memperbaiki atau menghapusnya.
  • Jalankan test karantina di job terpisah agar tetap dipantau.
  • Laporkan jumlah test karantina dan tren kegagalannya.

Tanpa disiplin ini, karantina berubah menjadi cara formal untuk menormalisasi kualitas test yang buruk.

Contoh alur verifikasi flaky test di CI

Berikut alur praktis yang bisa diterapkan tanpa bergantung pada framework tertentu:

  1. Jalankan suite utama tanpa retry default agar sinyal kegagalan tetap jujur.
  2. Jika gagal, simpan artifact: log, seed, screenshot, trace, request/response, daftar test yang berjalan sebelum gagal.
  3. Jalankan ulang test yang gagal secara terisolasi untuk melihat apakah masalah muncul hanya dalam suite penuh.
  4. Jalankan ulang dengan urutan berbeda atau mode paralel/non-paralel untuk mendeteksi kebocoran state dan race condition.
  5. Klasifikasikan penyebab: data bersama, waktu, network, cache, urutan, atau bug produk.
  6. Perbaiki root cause. Retry hanya dipakai bila ada alasan kuat dan bersifat sementara.
  7. Tambahkan guardrail seperti fake clock, reset cache, seeding, atau isolasi DB agar masalah tidak kembali.

Secara konseptual, pipeline bisa dipisah seperti ini:

stages:
  - test
  - flaky-diagnostics

main-test-job:
  run: test-suite --no-retry --report-artifacts

rerun-failed-job:
  when: on_failure
  run: test-suite --failed-only --collect-debug-info

order-randomization-job:
  schedule: nightly
  run: test-suite --random-order --record-seed

quarantine-job:
  run: test-suite --group quarantine

Format di atas hanya ilustrasi. Intinya adalah memisahkan jalur verifikasi utama, diagnosis, dan karantina.

Checklist investigasi flaky test

Saat satu test mulai flaky, gunakan checklist berikut sebelum menambah retry:

  • Apakah test membuat dan membersihkan datanya sendiri?
  • Apakah ada cache, singleton, atau state global yang tidak di-reset?
  • Apakah test bergantung pada waktu sekarang, timezone, atau timeout ketat?
  • Apakah ada randomness tanpa seed?
  • Apakah assertion bergantung pada urutan data yang tidak eksplisit?
  • Apakah test memanggil service eksternal atau internet?
  • Apakah test hanya gagal saat paralel?
  • Apakah test lulus saat dijalankan sendiri?
  • Apakah ada perubahan performa yang membuat polling/timeout tidak memadai?
  • Apakah failure message menunjukkan bug produk, bukan sekadar masalah test?

Trade-off: kecepatan versus reliabilitas

Mengurangi flaky test hampir selalu melibatkan trade-off. Isolasi yang lebih ketat bisa menambah waktu setup. Menghapus network nyata meningkatkan stabilitas, tetapi mengurangi cakupan integrasi end-to-end. Menjalankan reset database per test lebih aman, tetapi lebih lambat dibanding per suite.

Pendekatan yang biasanya masuk akal:

  • Unit test: sangat terisolasi, tanpa network, fake clock, data minimal, sangat cepat.
  • Integration test: boleh menyentuh database atau antrean lokal, tetapi tetap deterministik dan dapat di-reset.
  • End-to-end test: jumlah lebih sedikit, fokus pada jalur kritis, timeout lebih realistis, observabilitas lebih kaya.

Tujuannya bukan membuat semua test supermahal demi reliabilitas absolut. Tujuannya adalah menempatkan isolasi yang tepat pada level test yang tepat, sehingga CI tetap cepat namun sinyal kegagalannya dapat dipercaya.

Kesalahan umum yang perlu dihindari

  • Menggunakan sleep tetap sebagai sinkronisasi utama.
  • Mengandalkan data bawaan environment alih-alih membuat fixture sendiri.
  • Mengakses internet dari test biasa tanpa sadar.
  • Mengabaikan timezone dan perbedaan locale.
  • Menambah retry global untuk “menghijaukan” pipeline.
  • Memindahkan test ke karantina tanpa tindak lanjut.
  • Menganggap flaky test tidak penting. Efeknya kumulatif: developer kehilangan kepercayaan pada CI dan mulai mengabaikan kegagalan yang sebenarnya valid.

Penutup

Cara paling efektif untuk mengurangi flaky test di CI dengan isolasi data dan kontrol waktu adalah membuat test benar-benar deterministik. Isolasi data mencegah kebocoran antar-test. Fake clock menghilangkan ketergantungan pada waktu nyata. Seed randomness membuat kegagalan bisa direproduksi. Mock network dan reset cache menjaga state tetap terkendali. Retry hanya dipakai secara terukur, bukan sebagai penutup masalah.

Jika tim Anda ingin mulai dari langkah yang paling berdampak, urutannya biasanya seperti ini: hilangkan network eksternal dari test biasa, standarkan timezone, tambahkan fake clock, pastikan setiap test membuat data sendiri, lalu audit cache dan state global. Dari sana, flaky test akan jauh lebih mudah dikurangi karena sumber nondeterminismenya mulai hilang satu per satu.