Flaky test adalah test yang kadang lulus, kadang gagal, tanpa ada kode yang berubah. Penyebabnya selalu sesuatu yang gak dikendalikan test-nya sendiri: angka acak, waktu, data sisa test lain, atau jaringan. Bayangin alarm asap yang kadang bunyi waktu kamu cuma merebus air. Seminggu kemudian, gak ada lagi yang peduli waktu dia bunyi. Lalu suatu malam ada kebakaran sungguhan, dan semua orang tetap tidur. CI yang sering merah karena flaky juga begitu: tim terbiasa bilang "ah, flaky, jalankan ulang aja", dan bug asli ikut lolos.
🧪 Tebakan cepat: sebuah test memanggil
increment()tiga kali, dan tiapincrement()cuma menambah 1 kalauMath.random() > 0.5. Test-nya mengharapkanvaluejadi 3. Dari 100 kali run, kira-kira berapa yang lulus? Jawabannya ada di bagian "Sumber acak: suntikkan dari luar".
Kenapa test bisa flaky
Penyebab yang paling sering:
- Acak: hasilnya bergantung
Math.random()atau data palsu tanpa seed. - Waktu: test menebak sebuah proses selesai dalam sekian milidetik, atau memakai jam asli (
new Date()) yang berubah tiap hari. - State bersama: test memakai object, database, atau variabel yang sama, jadi data sisa test sebelumnya ikut terbawa. Hasilnya bergantung urutan test.
- Jaringan: test memanggil server sungguhan yang kadang lambat atau error.
Cara mengenalinya: jalankan test yang sama berkali-kali. Test yang sehat lulus 100%. Test flaky punya angka lulus di antara 0 dan 100%.
Sumber acak: suntikkan dari luar
// Flaky: hasilnya bergantung Math.random()
function createCounter() {
return {
value: 0,
increment() {
if (Math.random() > 0.5) this.value = this.value + 1;
},
};
}
Tiap increment() punya peluang 1/2 untuk menambah. Supaya value jadi 3, ketiganya harus beruntung: 1/2 × 1/2 × 1/2 = 1/8, sekitar 12,5%. Jadi dari 100 run, cuma sekitar 12 atau 13 yang lulus. Itu jawaban tebakannya.
Perbaikannya: sumber acaknya disuntikkan sebagai parameter. Kode sungguhan memberi Math.random, test memberi fungsi yang jawabannya sudah pasti.
function createCounter(rng) {
return {
value: 0,
increment() {
if (rng() > 0.5) this.value = this.value + 1;
},
};
}
function deterministicTest() {
const counter = createCounter(() => 0.9); // RNG selalu 0.9, selalu > 0.5
counter.increment();
counter.increment();
counter.increment();
return counter.value;
}
console.log(deterministicTest()); // 3
createCounter(rng)menerima fungsirng. Di aplikasi, panggil dengancreateCounter(Math.random).rng()dipanggil di dalamincrement(), menggantikanMath.random().valuedisimpan di dalam object counter, bukan di variabel global, jadi tiap counter mulai dari 0.() => 0.9selalu menjawab 0.9, jadi ketigaincrement()pasti menambah. Hasilnya selalu 3, lulus 100%.
State bersama: beforeEach bikin yang baru
let cart = createCart(); // dibuat sekali, dipakai semua test
it("menambah satu barang", () => {
cart.add("Buku");
expect(cart.size).toBe(1);
});
it("awalnya kosong", () => {
expect(cart.size).toBe(0);
});
Dengan urutan biasa, test kedua selalu gagal: expected 1 to be +0, karena Buku dari test pertama masih di keranjang. Kalau urutannya diacak (npx vitest run --sequence.shuffle, Vitest menulis Running tests with seed "3"), kadang test kedua jalan duluan dan keduanya lulus. Hasilnya bergantung urutan, jadi flaky.
Perbaikannya: tiap test mendapat keranjang baru.
let cart;
beforeEach(() => {
cart = createCart();
});
Di Laravel, trait RefreshDatabase melakukan hal yang sama untuk database: tiap test mulai dari database yang bersih.
Waktu: jangan menebak durasi
it("menampilkan data setelah dimuat", async () => {
muatData(); // butuh 40 sampai 160 ms
await new Promise((r) => setTimeout(r, 100)); // tebakan: 100 ms cukup
expect(status).toBe("selesai");
});
Kalau jaringannya lambat, 100 ms belum cukup: expected 'memuat' to be 'selesai'. Perbaikannya: tunggu prosesnya sendiri, bukan jamnya. await muatData(), atau await screen.findByText(...) di component test. Untuk timer di kode, pakai vi.useFakeTimers() lalu vi.advanceTimersByTime(1000). Untuk tanggal, vi.setSystemTime(new Date("2025-01-15")). Untuk server sungguhan, ganti dengan mock seperti MSW.
Retry: obat sementara, bukan solusi
Vitest dan Playwright bisa mengulang test yang gagal:
it("operasi async", { retry: 3 }, async () => {
// ...
});
Kalau lulus di percobaan ketiga, laporannya bilang ✓ ... (retry x2) dan CI hijau. Masalahnya, retry menyembunyikan akar masalah tanpa memperbaikinya: timing, state, atau jaringannya tetap rusak, dan bug asli yang kadang muncul ikut tertutup. Pakai retry cuma untuk hal di luar kendalimu (misalnya jaringan CI yang kadang putus), catat test-nya, dan perbaiki akarnya. Untuk memastikan test sudah stabil, jalankan berulang kali, misalnya npx playwright test --repeat-each=20.
⚠️ Jebakan yang sering ketemu
- Langsung retry. CI jadi hijau palsu, dan akar masalahnya tetap ada.
setTimeoutdengan angka tebakan. Di laptop cepat lulus, di CI yang lambat gagal.- Object dibuat sekali di luar test. Data test satu bocor ke test lain. Buat ulang di
beforeEach. Math.random()ataunew Date()langsung di kode yang dites. Suntikkan, atau kunci dengan fake timers.
TL;DR: Flaky test kadang lulus kadang gagal tanpa perubahan kode, karena bergantung pada hal di luar kendalinya: acak, waktu, state bersama, atau jaringan. Perbaiki akarnya: suntikkan sumber acak (createCounter(rng), lalu di test () => 0.9), buat state baru di beforeEach, tunggu prosesnya sendiri atau pakai fake timers, dan mock jaringan. Retry bukan solusi karena menyembunyikan akar masalah tanpa memperbaikinya. Kenali flaky test dengan menjalankannya berkali-kali: test yang sehat lulus 100%.