Flaky test adalah test yang kadang lulus, kadang gagal, padahal kode tidak berubah. Di Rust, masalah ini sering muncul pada kode async, operasi yang bergantung pada waktu, serta interaksi dengan filesystem atau jaringan. Strategi test anti-flaky untuk async, time, dan IO bukan sekadar menambah retry; yang lebih penting adalah membuat perilaku sistem deterministik, membatasi sumber nondeterminisme, dan memisahkan area yang memang perlu diuji secara integrasi.
Jika Anda sering melihat test gagal karena timeout, urutan eksekusi task berubah, file masih terkunci, atau port sudah dipakai proses lain, akar masalahnya hampir selalu sama: test bergantung pada kondisi eksternal yang tidak stabil. Di Rust, solusi praktisnya adalah dependency injection untuk clock dan IO, test double, isolasi fixture, pembatasan konkurensi, dan pemisahan yang tegas antara unit test dan integration test.
Mengapa test Rust menjadi flaky?
1. Race condition pada async task
Pada kode async, urutan eksekusi tidak boleh diasumsikan stabil kecuali memang dikendalikan. Test yang menunggu task selesai dengan urutan implisit sering gagal secara acak, terutama jika ada spawn, channel, shared state, atau beberapa task yang saling berebut resource.
2. Timeout yang rapuh
Test yang mengandalkan angka waktu kecil, misalnya menunggu 10 ms lalu berharap state sudah berubah, cenderung tidak stabil. Beban CPU, scheduler runtime, atau CI yang lebih lambat bisa membuat durasi itu tidak cukup. Timeout sebaiknya dipakai sebagai safety net, bukan sebagai mekanisme utama verifikasi perilaku.
3. Ketergantungan pada clock nyata
Kode yang memakai waktu sistem langsung, seperti std::time::SystemTime::now() atau penundaan riil, sulit diuji secara deterministik. Hasil test bisa berubah tergantung kecepatan mesin, zona waktu, atau delay acak dari scheduler.
4. IO eksternal: filesystem dan jaringan
Filesystem bisa dipengaruhi permission, path yang bentrok, file lock, urutan cleanup, atau sisa file dari test lain. Jaringan lebih rawan lagi: port collision, koneksi lambat, DNS, dependency service yang tidak siap, dan latensi acak.
5. Shared state antar test
Test Rust bisa berjalan paralel. Jika beberapa test memakai direktori sementara yang sama, environment variable global, singleton mutable, atau database yang sama tanpa isolasi, hasilnya mudah menjadi flaky.
Prinsip utama: buat test deterministik
Tujuan utama bukan meniru produksi selengkap mungkin di semua level, melainkan menguji kontrak perilaku secara stabil. Prinsip yang biasanya efektif di Rust:
- Jangan membaca clock nyata langsung di domain logic.
- Jangan menulis unit test yang bergantung pada sleep riil.
- Abstraksikan IO di balik trait.
- Gunakan data dan fixture yang unik per test.
- Verifikasi sinyal yang eksplisit, misalnya pesan channel, hasil fungsi, atau state final, bukan sekadar menunggu waktu lewat.
- Batasi konkurensi bila test menyentuh resource global atau mahal.
Dependency injection untuk clock dan waktu
Pola paling penting untuk kode berbasis waktu adalah memisahkan sumber waktu dari logika bisnis. Daripada memanggil waktu sistem langsung, injeksikan clock melalui trait. Dengan begitu test bisa memakai clock palsu yang nilainya Anda kontrol sendiri.
Contoh: injeksi clock ke logika kedaluwarsa
use std::time::{Duration, Instant};
trait Clock {
fn now(&self) -> Instant;
}
struct SystemClock;
impl Clock for SystemClock {
fn now(&self) -> Instant {
Instant::now()
}
}
struct FakeClock {
now: Instant,
}
impl FakeClock {
fn new(now: Instant) -> Self {
Self { now }
}
fn advance(&mut self, d: Duration) {
self.now += d;
}
}
impl Clock for FakeClock {
fn now(&self) -> Instant {
self.now
}
}
struct Session<C: Clock> {
created_at: Instant,
ttl: Duration,
clock: C,
}
impl<C: Clock> Session<C> {
fn is_expired(&self) -> bool {
self.clock.now().duration_since(self.created_at) >= self.ttl
}
}
#[test]
fn session_expired_deterministically() {
let start = Instant::now();
let mut clock = FakeClock::new(start);
let session = Session {
created_at: start,
ttl: Duration::from_secs(30),
clock,
};
assert!(!session.is_expired());
}
Contoh di atas menunjukkan ide dasarnya: domain logic tidak tahu apakah waktu datang dari sistem nyata atau fake clock. Pada implementasi nyata, Anda mungkin memilih menyimpan clock di balik reference atau pointer bersama agar bisa di-advance dari test.
Catatan: Untuk logika waktu, sering kali lebih aman memakai
InstantdaripadaSystemTimejika yang diuji adalah durasi atau timeout.Instantcocok untuk pengukuran monoton dan tidak dipengaruhi perubahan jam sistem.
Mengapa pendekatan ini bekerja?
Karena test tidak lagi menunggu waktu riil lewat. Anda mengontrol waktu sebagai input, sama seperti mengontrol parameter fungsi. Ini menghilangkan ketergantungan pada scheduler, performa mesin, dan beban CI.
Async test: verifikasi event, bukan sleep
Kesalahan umum pada test async adalah memakai sleep lalu berharap side effect sudah terjadi. Cara yang lebih stabil adalah menunggu event yang eksplisit: pesan channel diterima, future selesai, atau state berubah lewat sinkronisasi yang jelas.
Contoh rapuh
use tokio::time::{sleep, Duration};
#[tokio::test]
async fn flaky_example() {
let handle = tokio::spawn(async {
// kerja async
});
sleep(Duration::from_millis(20)).await;
// asumsi task sudah selesai di sini
handle.await.unwrap();
}
Test di atas rapuh karena 20 ms hanyalah tebakan. Di mesin lambat atau CI sibuk, task bisa belum selesai.
Contoh lebih deterministik dengan channel
use tokio::sync::oneshot;
use tokio::time::{timeout, Duration};
async fn do_work(done: oneshot::Sender<&'static str>) {
let _ = done.send("ok");
}
#[tokio::test]
async fn async_work_signals_completion() {
let (tx, rx) = oneshot::channel();
tokio::spawn(do_work(tx));
let result = timeout(Duration::from_secs(1), rx).await;
let msg = result.expect("task timeout").expect("sender dropped");
assert_eq!(msg, "ok");
}
Di sini, timeout dipakai untuk mencegah test menggantung selamanya, bukan sebagai cara utama memastikan urutan. Sinyal keberhasilan berasal dari channel, sehingga verifikasi lebih deterministik.
Kapan timeout tetap diperlukan?
Timeout tetap berguna sebagai pagar pengaman. Tanpa timeout, deadlock atau bug sinkronisasi bisa membuat suite macet. Tetapi timeout harus cukup longgar agar tidak memicu kegagalan acak, dan logika verifikasi tetap berbasis event yang jelas.
Test double untuk IO: filesystem dan jaringan
Jika unit test menyentuh disk atau jaringan nyata, Anda sedang menguji lebih dari satu hal sekaligus: logika aplikasi dan kestabilan lingkungan. Di Rust, cara yang lebih aman adalah mengabstraksikan IO melalui trait, lalu menyediakan implementasi nyata untuk produksi dan test double untuk unit test.
Contoh: abstraksi penyimpanan file
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
trait BlobStore: Send + Sync {
fn put(&self, key: &str, data: Vec<u8>);
fn get(&self, key: &str) -> Option<Vec<u8>>;
}
struct InMemoryBlobStore {
inner: Arc<Mutex<HashMap<String, Vec<u8>>>>,
}
impl InMemoryBlobStore {
fn new() -> Self {
Self {
inner: Arc::new(Mutex::new(HashMap::new())),
}
}
}
impl BlobStore for InMemoryBlobStore {
fn put(&self, key: &str, data: Vec<u8>) {
self.inner.lock().unwrap().insert(key.to_string(), data);
}
fn get(&self, key: &str) -> Option<Vec<u8>> {
self.inner.lock().unwrap().get(key).cloned()
}
}
struct DocumentService<S: BlobStore> {
store: S,
}
impl<S: BlobStore> DocumentService<S> {
fn save_text(&self, key: &str, text: &str) {
self.store.put(key, text.as_bytes().to_vec());
}
}
#[test]
fn save_document_without_real_fs() {
let store = InMemoryBlobStore::new();
let service = DocumentService { store };
service.save_text("invoice-1", "paid");
}
Keuntungan pendekatan ini:
- Unit test cepat dan stabil.
- Tidak perlu cleanup file nyata.
- Tidak bentrok dengan test paralel lain.
- Lebih mudah mensimulasikan error path.
Bagaimana dengan integration test?
Integration test tetap penting untuk memastikan implementasi nyata bekerja. Bedanya, jumlahnya lebih sedikit dan cakupannya lebih terarah. Jangan memindahkan semua test ke level integrasi hanya karena lebih terasa “realistis”; biasanya itu justru memperbesar peluang flaky.
Fixture terisolasi dan data deterministik
Gunakan direktori sementara unik per test
Jika test memang harus menyentuh filesystem nyata, buat direktori unik untuk setiap test dan pastikan cleanup jelas. Hindari path statis seperti /tmp/app-test yang dipakai bersama.
use std::fs;
use std::path::PathBuf;
#[test]
fn isolated_fs_fixture() {
let base = std::env::temp_dir();
let unique = format!(
"rust-test-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
);
let dir: PathBuf = base.join(unique);
fs::create_dir_all(&dir).unwrap();
let file = dir.join("data.txt");
fs::write(&file, b"hello").unwrap();
let content = fs::read(&file).unwrap();
assert_eq!(content, b"hello");
fs::remove_dir_all(&dir).unwrap();
}
Pada proyek nyata, Anda juga bisa memakai helper internal untuk fixture agar pola ini konsisten di seluruh suite.
Hindari data acak tanpa seed yang jelas
Data acak bisa berguna, tetapi tanpa seed yang bisa direproduksi, debugging menjadi sulit. Jika Anda memakai generator data, simpan seed saat test gagal atau gunakan data uji yang tetap untuk skenario yang tidak membutuhkan variasi acak.
Mengelola shared state dan konkurensi test
Rust mendorong keamanan memori, tetapi itu tidak otomatis membuat test bebas dari shared state. Beberapa sumber masalah umum:
- Environment variable yang diubah beberapa test sekaligus.
- Singleton global dengan mutasi internal.
- Database, cache, atau queue yang dipakai bersama tanpa namespace unik.
- Port jaringan tetap yang diperebutkan beberapa test.
Strategi pencegahan
- Gunakan namespace unik per test untuk key database, file, atau queue.
- Hindari port hard-coded jika memungkinkan.
- Serialkan test tertentu bila menyentuh resource global yang memang tidak bisa diparalelkan.
- Reset state secara eksplisit di setup/teardown.
Trade-off-nya jelas: menjalankan lebih banyak test secara serial dapat memperlambat suite. Namun untuk test yang sangat sensitif terhadap resource global, sedikit lebih lambat jauh lebih baik daripada suite yang tidak dipercaya tim.
Pemisahan unit test dan integration test
Banyak flaky test lahir karena semua skenario dipaksa lewat jalur penuh: async runtime, jaringan, filesystem, database, dan waktu nyata sekaligus. Pisahkan level pengujian:
Unit test
- Fokus pada logika.
- Pakai fake clock dan test double IO.
- Harus cepat, deterministik, dan berjalan paralel.
Integration test
- Memverifikasi integrasi nyata dengan filesystem, database, atau service lokal.
- Jumlah lebih sedikit.
- Perlu fixture yang terisolasi, timeout yang masuk akal, dan logging yang cukup.
Pemisahan ini penting karena target keduanya berbeda. Unit test menjaga regresi logika harian. Integration test menguji kontrak antar komponen. Mencampur keduanya biasanya memperburuk stabilitas dan memperlambat feedback.
Pola verifikasi yang lebih deterministik
Berikut pola yang umumnya lebih stabil di Rust:
- Tunggu future atau handle secara eksplisit, bukan sleep lalu assert.
- Gunakan channel atau sinkronisasi eksplisit untuk mengetahui kapan efek samping selesai.
- Assert state final yang benar-benar relevan, bukan indikator sementara yang bisa berubah urutan.
- Gunakan timeout sebagai guard, bukan sumber kebenaran.
- Uji error path dengan fake implementation yang memang memaksa error, bukan menunggu error nyata terjadi secara acak.
Debugging flaky test di Rust
1. Identifikasi sumber nondeterminisme
Tanya: apakah test ini bergantung pada waktu nyata, urutan task, file bersama, port tetap, environment global, atau data acak?
2. Jalankan berulang
Flaky test sering hanya muncul sesekali. Jalankan test yang dicurigai berkali-kali atau dalam kondisi CI yang lebih mirip produksi untuk memperbesar peluang reproduksi.
3. Tambahkan observabilitas secukupnya
Logging pada titik sinkronisasi, pengiriman channel, perubahan state, atau setup fixture sangat membantu. Namun jangan sampai log menjadi mekanisme sinkronisasi terselubung.
4. Cari assert yang terlalu dini
Sering kali bukan implementasinya yang salah, tetapi test melakukan assert sebelum sistem mencapai kondisi yang benar-benar stabil.
5. Evaluasi ulang desain API
Jika komponen sangat sulit diuji tanpa sleep dan resource nyata, mungkin desainnya terlalu erat dengan clock atau IO. Masalah test sering mengungkap masalah desain yang memang perlu dibenahi.
Checklist CI untuk deteksi regresi dan stabilisasi suite test
- Pisahkan job unit dan integration test. Unit test harus menjadi sinyal cepat dan stabil.
- Tetapkan timeout per job dan per test yang wajar. Hindari timeout terlalu agresif.
- Jalankan test dengan log yang cukup agar kegagalan async/IO mudah ditelusuri.
- Ulangi test yang baru diperbaiki secara lokal sebelum merge untuk memeriksa stabilitas.
- Batasi konkurensi untuk suite yang menyentuh resource global atau environment terbatas.
- Pastikan fixture unik per eksekusi: path, nama database, key prefix, dan port bila relevan.
- Laporkan test yang sering gagal dan perlakukan sebagai bug teknis, bukan noise biasa.
- Jangan menormalkan retry tanpa perbaikan akar masalah. Retry kadang berguna sementara, tetapi tidak menyelesaikan nondeterminisme.
Penutup
Strategi test anti-flaky untuk async, time, dan IO di Rust berpusat pada satu hal: kendalikan sumber nondeterminisme. Pada praktiknya, itu berarti menginjeksikan clock dan IO, memakai test double, memverifikasi event yang eksplisit alih-alih sleep, mengisolasi fixture, membatasi konkurensi saat perlu, dan memisahkan unit test dari integration test.
Jika suite test Anda sering gagal acak, jangan langsung menambah durasi sleep atau retry. Periksa dulu apakah test bergantung pada waktu nyata, race condition, filesystem/jaringan, atau shared state. Biasanya, perbaikan desain kecil pada batas komponen akan memberi hasil jauh lebih stabil dibanding menambal gejalanya di level CI.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!