Flaky test E2E di CI tanpa menambah timeout bisa distabilkan, tetapi caranya bukan dengan "memberi waktu lebih banyak" ke seluruh suite. Timeout global yang lebih besar hanya menunda kegagalan, memperlambat pipeline, dan sering menutupi akar masalah seperti race condition, data uji bersama, dependensi waktu, respons jaringan yang tidak stabil, atau perbedaan performa environment CI.

Jika sebuah test kadang lulus dan kadang gagal tanpa perubahan kode, anggap itu sebagai masalah determinisme. Solusi yang efektif biasanya melibatkan tiga hal: diagnosis yang bisa diulang, isolasi state antar test, dan sinyal kegagalan yang lebih jelas. Dengan begitu, Anda bisa memperbaiki penyebab sebenarnya tanpa menjadikan pipeline makin lambat.

Mengenali gejala flaky test di CI

Flaky test E2E jarang muncul sebagai error yang konsisten. Gejalanya sering terlihat seperti:

  • Test gagal hanya di CI, tetapi stabil di lokal.
  • Test gagal pada langkah UI tertentu, lalu lulus saat di-run ulang.
  • Kegagalan berpindah-pindah antar test tanpa pola yang jelas.
  • Failure message terlalu umum, misalnya elemen tidak ditemukan, request timeout, atau assertion state belum sesuai.
  • Satu test gagal hanya saat dijalankan paralel, tetapi lulus saat dijalankan sendiri.

Pola ini penting karena mengarah ke penyebab berbeda. Misalnya, kegagalan yang hanya muncul saat paralel biasanya terkait state bersama. Kegagalan yang hanya muncul di CI sering berkaitan dengan performa host, timing rendering UI, atau dependensi eksternal yang lebih lambat.

Penyebab umum flaky test E2E

1. Race condition antara aksi test dan state aplikasi

Ini penyebab paling umum. Test menekan tombol, lalu langsung melakukan assertion sebelum UI selesai render, request selesai, job background selesai diproses, atau cache diperbarui. Di mesin lokal yang cepat, test terlihat stabil. Di CI yang lebih lambat, urutan state berubah dan test gagal.

Tanda khas: assertion terhadap teks, elemen, redirect, atau status data dilakukan terlalu dini.

2. Data uji dipakai bersama

Jika banyak test memakai akun, record, bucket, atau namespace yang sama, test bisa saling memengaruhi. Misalnya test A menghapus data yang sedang dipakai test B, atau test B membaca hasil modifikasi dari test sebelumnya.

Tanda khas: test lulus saat dijalankan sendiri, tetapi gagal saat suite penuh atau mode paralel.

3. Ketergantungan pada waktu

Test yang bergantung pada jam sistem, zona waktu, TTL cache, token expiry, scheduler, atau delay eksplisit sering menjadi flaky. CI juga bisa punya latency yang membuat asumsi seperti "setelah 2 detik pasti selesai" tidak berlaku.

4. Ketergantungan jaringan dan layanan eksternal

E2E yang bergantung langsung ke API pihak ketiga, email provider, payment sandbox, atau layanan notifikasi akan mewarisi ketidakstabilan jaringan dan perilaku eksternal. Masalahnya bukan hanya lambat, tetapi juga non-deterministik.

5. Async UI dan animasi

UI modern sering memuat data bertahap, melakukan hydration, transisi halaman, debounce input, optimistic update, atau background fetch. Jika test berasumsi DOM final sudah siap padahal belum, flake akan muncul.

6. Environment CI lebih lambat dan lebih bising

CPU yang dibagi, I/O yang lebih lambat, kontensi resource, cold start container, dan startup service yang tidak serempak membuat timing di CI berbeda dari lokal. Ini bukan alasan menaikkan timeout global, melainkan alasan untuk membuat test menunggu kondisi yang benar, bukan menunggu waktu tetap.

Prinsip utama: tunggu kondisi, bukan durasi

Timeout global besar adalah solusi malas karena tidak meningkatkan determinisme. Pendekatan yang lebih tepat adalah menunggu sinyal yang benar-benar menandakan sistem siap, misalnya:

  • respons API tertentu sudah selesai,
  • elemen UI sudah muncul dan dalam state yang bisa diinteraksi,
  • spinner atau skeleton sudah hilang,
  • record database dengan kriteria tertentu sudah terbentuk,
  • event atau job yang dibutuhkan sudah selesai diproses.

Dengan kata lain, ganti asumsi "tunggu 5 detik" menjadi "tunggu sampai kondisi X benar, dengan batas waktu yang wajar". Ini lebih cepat saat sistem responsif, dan lebih andal saat sistem lambat.

Strategi diagnosis yang benar

1. Pastikan test benar-benar flaky

Jangan langsung memberi label flaky jika test selalu gagal pada commit tertentu. Itu bisa jadi regresi nyata. Test layak disebut flaky jika hasilnya berubah-ubah tanpa perubahan kode yang relevan.

2. Reproduksi dengan pengulangan

Jalankan test yang sama berkali-kali di CI atau environment yang menyerupai CI. Tujuannya bukan sekadar membuktikan bahwa test kadang gagal, tetapi menemukan langkah mana yang paling sering gagal.

# Contoh pseudocode shell untuk mengulang test tertentu beberapa kali
for i in $(seq 1 20); do
  echo "Run #$i"
  ./run-e2e --test "checkout flow" || break
done

Jika kegagalan hanya muncul saat test lain ikut berjalan, ulangi dalam subset suite yang lebih kecil untuk mengisolasi interaksi antar test.

3. Rekam artefak kegagalan

Tanpa observability, flaky test hanya menghasilkan tebakan. Simpan artefak berikut setiap kali gagal:

  • screenshot pada langkah gagal,
  • video atau trace interaksi browser,
  • log aplikasi server,
  • log browser console,
  • network log atau daftar request penting,
  • status data yang relevan sebelum dan sesudah assertion.

Artefak ini membantu membedakan apakah masalah ada di UI, API, data, atau environment.

4. Klasifikasikan kegagalan

Kelompokkan failure ke kategori yang jelas:

  • State belum siap: elemen belum muncul, request belum selesai.
  • Data bocor: state awal test tidak bersih.
  • External dependency: request ke pihak ketiga gagal atau lambat.
  • Clock/time issue: token expired, scheduler belum jalan, zona waktu berbeda.
  • Infra issue: service dependency belum sehat, resource CI habis.

Tanpa klasifikasi, tim mudah terjebak menambahkan retry atau sleep di tempat yang salah.

Teknik perbaikan yang paling efektif

Isolasi test dan state

Setiap test idealnya dapat berjalan sendiri, dalam urutan apa pun, dan tetap menghasilkan output yang sama. Cara praktis mencapainya:

  • Buat data uji unik per test, misalnya dengan ID acak atau namespace run.
  • Jangan memakai akun atau record global yang dipakai banyak test.
  • Reset state yang diubah test, atau lebih baik buat state sementara yang bisa dibuang.
  • Jika memakai database sungguhan, siapkan data minimum yang diperlukan untuk tiap test.

Contoh pola penamaan data yang lebih aman:

test_run_id = env("CI_RUN_ID") || timestamp()
user_email = "e2e+" + test_run_id + "+checkout@example.com"
order_ref = "e2e-order-" + test_run_id

Poin pentingnya bukan formatnya, tetapi memastikan test tidak berebut entitas yang sama.

Buat data uji deterministik

Deterministik berarti input dan kondisi awal test bisa diprediksi. Hindari bergantung pada data yang tersisa dari run sebelumnya atau seed yang tidak terkontrol.

  • Siapkan fixture atau factory yang eksplisit.
  • Jangan membaca record "terbaru" jika urutannya bisa berubah.
  • Jangan memilih elemen berdasarkan teks yang dinamis jika ada selector yang lebih stabil.
  • Jika aplikasi memakai waktu saat ini, pertimbangkan kontrol clock pada layer yang bisa diuji.

Kesalahan umum adalah assertion pada data yang dihasilkan proses asinkron tanpa memastikan proses tersebut sudah selesai.

Gunakan mocking secara tepat, bukan berlebihan

Untuk E2E, tujuan test bukan selalu memverifikasi seluruh dunia luar. Jika ketergantungan eksternal tidak menjadi fokus skenario, lebih aman mengganti bagian itu dengan stub atau fake yang terkontrol.

Contoh yang sering layak dimock:

  • email dan SMS gateway,
  • payment provider sandbox yang tidak stabil,
  • layanan geocoding, anti-fraud, atau analytics,
  • webhook pihak ketiga.

Namun ada trade-off. Terlalu banyak mocking membuat E2E kehilangan nilai integrasinya. Pilihan praktisnya:

  • Mock dependency yang tidak relevan untuk acceptance criteria test.
  • Integrasikan sungguhan hanya pada jalur kritikal yang memang perlu dibuktikan end-to-end.
  • Tambahkan test kontrak atau integration test terpisah untuk dependency penting.

Prinsipnya: jangan biarkan flaky test E2E menjadi proksi untuk menguji stabilitas vendor eksternal.

Retry yang terukur dan terbatas

Retry bukan solusi utama, tetapi bisa menjadi kontrol sementara atau pelindung untuk noise infra yang sesekali terjadi. Retry yang sehat memiliki ciri:

  • jumlah percobaan kecil,
  • hanya untuk test yang sudah dipahami sumber flakenya,
  • setiap retry tetap menyimpan artefak kegagalan pertama,
  • hasil retry dipakai sebagai sinyal kualitas, bukan disembunyikan.

Hindari retry global yang membuat suite tampak hijau padahal kualitas memburuk. Test yang sering lulus di retry kedua tetap perlu dibenahi dan dicatat.

Setup dan teardown yang idempoten

Setup idempoten berarti aman dipanggil berulang tanpa menciptakan state rusak atau duplikat yang tak terduga. Ini penting saat job CI diulang, step test gagal di tengah jalan, atau cleanup sebelumnya tidak sempat berjalan.

# Pseudocode setup/teardown yang lebih aman
setup_test_context(run_id):
  ensure_bucket_exists("e2e-" + run_id)
  ensure_user_exists("e2e+" + run_id + "@example.com")
  clear_orders_for_run(run_id)

teardown_test_context(run_id):
  delete_orders_for_run(run_id)
  archive_logs_for_run(run_id)

Idempotensi mengurangi risiko test berikutnya mewarisi sisa state yang tidak disengaja.

Observability khusus untuk test

Test E2E membutuhkan sinyal yang sering tidak tersedia di produksi user flow biasa. Tambahkan observability yang membantu test, misalnya:

  • endpoint health untuk memastikan dependency siap sebelum suite dimulai,
  • log korelasi menggunakan test run ID,
  • event domain penting yang bisa dipantau,
  • status job background atau queue untuk menunggu kondisi secara eksplisit,
  • snapshot state aplikasi saat assertion gagal.

Ini bukan berarti membocorkan detail internal ke test secara berlebihan. Gunakan observability untuk mengurangi blind spot, bukan membuat test terlalu terikat pada implementasi.

Contoh alur investigasi flaky test

Misalkan ada test checkout yang kadang gagal pada assertion "status pesanan menjadi Paid". Tim sebelumnya menambah timeout menjadi 30 detik, tetapi flake tetap muncul.

  1. Reproduksi: test dijalankan 20 kali di environment CI-like. Gagal 4 kali.
  2. Kumpulkan artefak: screenshot menunjukkan UI masih menampilkan status Processing, sementara request pembayaran sudah sukses.
  3. Lihat network dan log aplikasi: callback payment diterima, tetapi ada job async yang mengubah status order setelah callback.
  4. Temukan race condition: test melakukan assertion segera setelah redirect sukses, padahal status final tergantung job background.
  5. Perbaikan: ubah test agar menunggu kondisi yang relevan, misalnya indikator status final yang muncul setelah job selesai, atau tunggu event/status order yang memang menandakan workflow lengkap.
  6. Verifikasi: jalankan ulang 20-50 kali. Jika stabil, pertahankan. Jika belum, cek apakah ada faktor lain seperti data order bersama atau worker queue yang belum siap di CI.

Dari contoh ini, timeout besar tidak menyelesaikan akar masalah karena yang dibutuhkan bukan waktu lebih lama secara umum, melainkan sinkronisasi terhadap kondisi bisnis yang benar.

Kesalahan yang sering dilakukan

  • Menambah sleep tetap: membantu sesaat, lalu gagal lagi saat CI lebih lambat dari biasanya.
  • Memakai selector rapuh: bergantung pada teks dinamis, posisi DOM, atau class CSS yang sering berubah.
  • Mengandalkan urutan test: test B hanya lulus jika test A berjalan lebih dulu.
  • Berbagi akun admin tunggal: memicu konflik session, data, dan permission.
  • Mencampur verifikasi UI dan backend tanpa batas: test menjadi sulit dipahami dan rawan gagal karena banyak lapisan berubah sekaligus.
  • Menyembunyikan masalah dengan retry tanpa metrik: suite tampak hijau, tetapi waktu debug justru meningkat.

Checklist perbaikan flaky test E2E di CI

  • Apakah test bisa dijalankan sendiri dan tetap lulus konsisten?
  • Apakah test memakai data unik per run atau per case?
  • Apakah assertion menunggu kondisi yang tepat, bukan delay tetap?
  • Apakah dependency eksternal yang tidak relevan sudah dimock atau distub?
  • Apakah setup dan teardown aman jika dipanggil ulang?
  • Apakah ada artefak lengkap saat gagal: screenshot, log, network, trace?
  • Apakah test tetap stabil saat dijalankan paralel?
  • Apakah ada dependensi waktu, zona waktu, TTL, atau expiry yang tak terkontrol?
  • Apakah service pendukung di CI dipastikan sehat sebelum suite mulai?
  • Apakah retry, jika dipakai, dibatasi dan dilaporkan sebagai sinyal kualitas?

Kebijakan quarantine test yang sehat

Terkadang ada test flaky yang belum sempat diperbaiki, tetapi terus mengganggu pipeline utama. Dalam kondisi ini, quarantine bisa dipakai, dengan disiplin yang jelas.

Kapan quarantine masuk akal

  • Flake sudah terkonfirmasi dan punya tiket perbaikan.
  • Test tersebut mengganggu throughput tim secara signifikan.
  • Penyebabnya sedang diinvestigasi atau menunggu perubahan arsitektur/dependency.

Aturan quarantine yang disarankan

  • Test yang di-quarantine dipindah dari gate utama, tetapi tetap dijalankan terpisah.
  • Harus ada owner dan tenggat evaluasi.
  • Rasio gagal/lulus tetap dipantau.
  • Quarantine bukan status permanen.

Tanpa aturan ini, quarantine berubah menjadi kuburan test: suite tampak sehat, tetapi coverage perilaku kritis diam-diam hilang.

Integrasi ke workflow verifikasi PR

Agar regresi cepat terdeteksi tanpa membuat feedback loop terlalu lama, pisahkan strategi verifikasi berdasarkan level risiko.

1. PR gate cepat untuk jalur kritikal

Pada setiap pull request, jalankan subset E2E yang pendek tetapi mewakili alur paling penting, misalnya login, checkout, atau pembuatan resource utama. Suite ini harus sangat stabil, minim dependency eksternal, dan punya observability baik.

2. Suite penuh berjalan setelah merge atau terjadwal

Suite E2E lengkap bisa dijalankan pada branch utama, nightly, atau sebelum rilis. Ini memberi cakupan lebih luas tanpa membebani setiap PR dengan waktu tunggu panjang.

3. Failure harus mengembalikan sinyal yang bisa ditindaklanjuti

Pastikan hasil CI menampilkan:

  • nama test dan langkah gagal,
  • tautan ke screenshot/video/log,
  • apakah test gagal pada percobaan pertama atau setelah retry,
  • kategori kegagalan jika bisa diidentifikasi otomatis.

4. Tambahkan deteksi regresi flaky

Jangan hanya melihat merah atau hijau. Pantau juga test yang sering butuh retry, test yang durasinya meningkat drastis, atau test yang gagal hanya pada worker tertentu. Metrik sederhana ini sering memberi sinyal lebih awal sebelum pipeline menjadi tidak dipercaya.

Rekomendasi implementasi lintas stack

Walau detail teknis tiap framework berbeda, prinsip berikut hampir selalu berlaku:

  • Gunakan selector yang stabil dan memang dibuat untuk test.
  • Tunggu event atau state final, bukan jumlah detik tertentu.
  • Minimalkan dependency lintas test.
  • Kontrol data, waktu, dan dependency eksternal sebisa mungkin.
  • Simpan artefak yang cukup untuk investigasi pasca-gagal.
  • Jadikan flaky test sebagai bug engineering, bukan kebiasaan yang diterima.

Jika harus memilih satu prioritas pertama, pilih isolasi state dan observability. Banyak flaky test terlihat seperti masalah timeout, padahal sebenarnya test tidak tahu kapan sistem benar-benar siap atau sedang berbagi state dengan test lain.

Penutup

Cara menstabilkan flaky test E2E di CI tanpa menambah timeout adalah dengan membuat test lebih deterministik, bukan lebih sabar. Fokuskan upaya pada diagnosis yang bisa diulang, sinkronisasi terhadap kondisi nyata, data uji yang terisolasi, dependency eksternal yang dikendalikan, dan sinyal kegagalan yang cukup kaya untuk debug.

Pipeline yang sehat bukan pipeline yang selalu hijau karena timeout besar atau retry berlapis. Pipeline yang sehat adalah pipeline yang cepat memberi tahu ketika ada regresi nyata, dan cukup stabil sehingga ketika test gagal, tim percaya bahwa kegagalan itu layak diselidiki.