Strategi test async Rust yang efektif harus dimulai dari satu fakta sederhana: kegagalan acak pada kode async biasanya bukan benar-benar acak. Penyebabnya hampir selalu terkait scheduling executor, future yang tidak pernah dipoll seperti yang diasumsikan, timeout yang rapuh, shared state lintas task, atau cancellation yang tidak pernah diuji secara eksplisit.
Jika tim backend hanya menambah sleep pada test sampai “terlihat hijau”, flaky test memang bisa tampak berkurang untuk sementara, tetapi regresi tersembunyi justru makin sulit dilacak. Pendekatan yang lebih aman adalah membuat test deterministik: kontrol waktu, batasi concurrency, pisahkan unit test dari integration test, mock I/O dengan jelas, dan verifikasi perilaku saat timeout, retry, cancellation, serta race condition.
Mengapa async Rust mudah menghasilkan flaky test
Pada kode sinkron, urutan eksekusi relatif langsung dibaca dari alur fungsi. Pada kode async, unit kerja dibungkus dalam future, lalu dipoll oleh executor. Ini berarti banyak asumsi implisit bisa salah:
- Urutan tidak dijamin: task A tidak selalu selesai sebelum task B, meski pada mesin developer sering tampak demikian.
- Polling bersifat oportunistik: future hanya maju saat dipoll. Jika test mengasumsikan progres otomatis tanpa pemicu yang tepat, hasilnya bisa tidak konsisten.
- Waktu bukan indikator stabil:
sleep(Duration::from_millis(...))sering hanya menutupi race, bukan menyelesaikannya. - Cancellation adalah jalur eksekusi nyata: task bisa dibatalkan saat menunggu lock, I/O, channel, atau timeout. Jika jalur ini tidak diuji, resource leak dan state korup bisa lolos.
- Shared state lintas task lebih rapuh: lock ordering, channel close, dan retry bersamaan sering memicu bug yang tidak terlihat dalam test biasa.
Dengan kata lain, test async yang baik bukan sekadar “menjalankan fungsi async di dalam runtime”, tetapi sengaja mendesain skenario yang memaksa perilaku runtime terlihat jelas dan dapat diverifikasi.
Test pyramid untuk async code
Pola yang paling praktis untuk tim backend adalah tetap memakai test pyramid, tetapi menyesuaikannya dengan karakter async.
1. Unit test async: fokus pada state machine dan kontrak kecil
Unit test sebaiknya menguji logika inti tanpa jaringan, tanpa database asli, dan tanpa ketergantungan pada scheduler eksternal. Untuk kode async, ini berarti:
- uji transisi state setelah future dipoll hingga selesai,
- verifikasi retry policy, timeout policy, dan cancellation handling pada level kecil,
- gunakan mock untuk dependency I/O, channel, atau clock bila memungkinkan.
Target utamanya adalah determinisme tinggi dan waktu eksekusi singkat. Jika sebuah unit test butuh port jaringan sungguhan atau menunggu beberapa detik, kemungkinan besar boundary pengujiannya terlalu besar.
2. Integration test async: validasi wiring dan perilaku lintas komponen
Integration test cocok untuk memverifikasi bahwa runtime, client, server, database, queue, dan konfigurasi bekerja bersama. Namun jumlahnya harus lebih sedikit karena lebih mahal dan lebih mudah flake.
Gunakan integration test untuk hal-hal seperti:
- request masuk memicu task background yang benar,
- timeout antar layanan dipropagasikan sesuai harapan,
- cancellation dari client membatalkan operasi downstream,
- retry tidak menggandakan side effect.
Jangan memindahkan seluruh beban validasi ke integration test. Semakin banyak skenario rumit hanya diuji di level ini, semakin sulit menemukan akar masalah saat CI gagal.
3. Sedikit end-to-end test, sangat selektif
End-to-end tetap berguna, tetapi untuk async Rust sebaiknya hanya menutup alur bisnis paling kritis. Gunakan untuk memastikan integrasi nyata, bukan untuk membuktikan semua kombinasi race dan timeout. Kasus-kasus itu lebih cocok ditangani di unit test dan integration test terkontrol.
Pola dasar test async yang deterministik
Hindari sleep sebagai mekanisme sinkronisasi utama
Ini kesalahan paling umum. Menunggu 50 ms atau 500 ms tidak membuktikan event sudah terjadi; itu hanya memberi kesempatan agar event mungkin terjadi. Di mesin CI yang sibuk, asumsi ini sering gagal.
Lebih baik gunakan sinyal eksplisit seperti:
- channel untuk memberi tahu bahwa task mencapai checkpoint tertentu,
Notifyatau mekanisme koordinasi sejenis,- mock yang mencatat panggilan lalu memberi test kesempatan untuk melanjutkan secara manual.
Contoh: sinkronisasi dengan channel, bukan sleep
use tokio::sync::oneshot;
use tokio::time::{timeout, Duration};
async fn worker(started: oneshot::Sender<()>) {
let _ = started.send(());
// lanjutkan pekerjaan async lain
}
#[tokio::test]
async fn worker_mengirim_sinyal_saat_dimulai() {
let (tx, rx) = oneshot::channel();
tokio::spawn(worker(tx));
timeout(Duration::from_secs(1), rx)
.await
.expect("worker tidak mulai tepat waktu")
.expect("sinyal start gagal dikirim");
}Pola ini lebih kuat karena test menunggu event yang spesifik, bukan menebak kapan event mungkin selesai.
Bungkus semua operasi tunggu dengan timeout eksplisit
Timeout pada test bukan hanya untuk performa CI. Ia juga mencegah deadlock, future menggantung, atau task yang tidak pernah selesai. Aturan praktisnya:
- semua await yang bisa macet pada dependency eksternal atau koordinasi antartask sebaiknya punya timeout,
- timeout harus cukup longgar untuk variasi CI, tetapi cukup ketat agar hang cepat terdeteksi,
- jangan gunakan satu timeout global besar untuk seluruh suite sebagai pengganti timeout lokal.
Timeout lokal membuat sumber macet lebih mudah diidentifikasi.
Isolasi shared state agar test tidak saling memengaruhi
Flaky test async sering muncul bukan karena satu test buruk, tetapi karena beberapa test berbagi state secara tak sengaja. Contoh umum:
- port jaringan statis yang bentrok saat test paralel,
- database schema atau key namespace yang sama,
- singleton global yang menyimpan cache, clock, atau konfigurasi,
- task background dari test sebelumnya masih hidup dan mengganggu test berikutnya.
Praktik yang sebaiknya diterapkan
- Gunakan resource unik per test, misalnya namespace key unik, file sementara terpisah, atau database terisolasi.
- Hindari global mutable state. Jika tidak bisa, sediakan reset yang eksplisit dan aman.
- Pastikan task background dihentikan di akhir test, baik melalui signal shutdown maupun join handle yang ditunggu.
- Jangan mengandalkan urutan test. Setiap test harus independen.
Jika sebuah test hanya lulus saat dijalankan sendiri, hampir selalu ada shared state, task bocor, atau asumsi scheduling yang tidak valid.
Mock I/O untuk menguji timeout, retry, dan error path
Untuk mencegah regresi tersembunyi, dependency I/O sebaiknya dibungkus di belakang trait atau boundary yang mudah diganti. Tujuannya bukan sekadar mempermudah mocking, tetapi memberi kontrol penuh atas urutan respons, delay, dan kegagalan.
Kapan mock lebih baik daripada layanan sungguhan
- saat ingin memverifikasi retry ke-1 gagal lalu retry ke-2 berhasil,
- saat ingin memastikan timeout memicu cancellation,
- saat ingin memaksa respons out-of-order,
- saat ingin menguji cabang error yang sulit dipicu secara andal pada layanan nyata.
Contoh pola retry yang dapat diuji
use std::sync::{Arc, Mutex};
#[derive(Clone)]
struct MockClient {
responses: Arc<Mutex<Vec<Result<&'static str, &'static str>>>>,
}
impl MockClient {
async fn fetch(&self) -> Result<&'static str, &'static str> {
self.responses.lock().unwrap().remove(0)
}
}
async fn fetch_with_retry(client: MockClient, max_attempts: usize) -> Result<&'static str, &'static str> {
let mut attempts = 0;
loop {
attempts += 1;
match client.fetch().await {
Ok(v) => return Ok(v),
Err(e) if attempts < max_attempts => continue,
Err(e) => return Err(e),
}
}
}
#[tokio::test]
async fn retry_berhasil_pada_percobaan_kedua() {
let client = MockClient {
responses: Arc::new(Mutex::new(vec![Err("timeout"), Ok("ok")]))
};
let result = fetch_with_retry(client, 2).await;
assert_eq!(result, Ok("ok"));
}Contoh ini sengaja sederhana, tetapi pola intinya penting: test mengontrol penuh urutan hasil. Dengan begitu, perilaku retry bisa diverifikasi tanpa jaringan sungguhan dan tanpa delay nyata.
Trade-off mock I/O
Mock memberi determinisme tinggi, tetapi bisa terlalu jauh dari perilaku sistem nyata jika boundary terlalu artifisial. Karena itu, kombinasikan:
- unit test dengan mock untuk semua cabang logika sensitif,
- integration test dengan dependency nyata untuk memvalidasi kontrak wire/protocol dan konfigurasi.
Verifikasi cancellation sebagai perilaku utama, bukan edge case
Pada sistem async, cancellation bukan kejadian langka. Client bisa putus koneksi, timeout bisa tercapai, service shutdown bisa diminta, dan task parent bisa selesai lebih dulu. Jika cancellation tidak diuji, bug berikut mudah lolos:
- lock tidak dilepas dengan benar,
- resource jaringan tetap terbuka,
- operasi separuh jalan meninggalkan state parsial,
- task anak tetap berjalan walau request induknya sudah batal.
Apa yang perlu diverifikasi
- Apakah operasi berhenti saat sinyal cancel datang?
- Apakah cleanup tetap berjalan?
- Apakah state tetap konsisten bila cancel terjadi di tengah proses?
- Apakah side effect bersifat idempoten atau setidaknya tidak terduplikasi saat retry sesudah cancel?
Untuk kasus ini, desain kode agar cancellation bisa dipicu secara eksplisit pada test, misalnya lewat channel shutdown atau future pembatalan yang diinjeksi.
Menguji race condition tanpa bergantung pada keberuntungan
Race condition sulit dibuktikan dengan satu test yang kebetulan lolos. Cara yang lebih masuk akal adalah mendesain test yang memaksa interleaving tertentu.
Gunakan barrier atau checkpoint terkontrol
Alih-alih berharap dua task berjalan bersamaan, buat keduanya berhenti pada checkpoint yang diketahui, lalu lepaskan secara terkendali. Ini membantu memicu urutan yang relevan, misalnya:
- dua task membaca state sebelum salah satunya menulis,
- cancel terjadi tepat ketika lock sudah diambil,
- retry dimulai saat respons lama belum sepenuhnya dibersihkan.
Fokus pada invariants
Untuk race condition, assertion paling berharga biasanya bukan urutan internal task, tetapi invariant eksternal seperti:
- record tidak dibuat ganda,
- counter tidak negatif,
- channel tidak mengirim event lebih dari sekali,
- resource selalu di-release pada akhir operasi.
Jika test terlalu detail pada urutan internal, ia cenderung rapuh dan terikat implementasi.
Pemisahan unit test dan integration test pada kode async Rust
Yang masuk unit test
- retry policy dan backoff decision,
- timeout handling pada boundary kecil,
- cancellation cleanup,
- mapping error,
- koordinasi task kecil dengan mock channel atau mock client.
Yang masuk integration test
- HTTP handler benar-benar membatalkan pekerjaan downstream saat client putus,
- pool koneksi, queue, atau database bekerja dengan lifecycle task yang sebenarnya,
- shutdown aplikasi menunggu task penting selesai atau membatalkannya dengan aman,
- telemetry/logging error path benar-benar tercatat pada alur async nyata.
Pemisahan ini membantu tim menghindari dua ekstrem yang sama buruknya: terlalu banyak mock hingga kontrak sistem tidak pernah diuji, atau terlalu banyak dependency nyata hingga suite menjadi lambat dan flake.
Checklist review PR untuk test async Rust
Gunakan checklist berikut saat meninjau PR yang menambah atau mengubah kode async:
- Apakah test menggunakan sleep sebagai sinkronisasi? Jika ya, apakah bisa diganti sinyal eksplisit?
- Apakah semua titik tunggu berisiko punya timeout yang jelas?
- Apakah ada shared state global, port statis, atau namespace data yang bisa bentrok antar test?
- Apakah task background selalu dihentikan atau di-join di akhir test?
- Apakah cancellation pernah diuji, bukan hanya jalur sukses?
- Apakah retry diverifikasi jumlah upayanya dan side effect-nya tidak ganda?
- Apakah error path dan timeout path diuji dengan mock I/O yang deterministik?
- Apakah assertion fokus pada invariant penting, bukan urutan internal yang rapuh?
- Jika test integration, apakah benar butuh dependency nyata?
- Jika test flake di CI, apakah akar masalah diperbaiki, bukan ditutup dengan retry test tanpa analisis?
Workflow CI untuk menandai flaky test tanpa menormalkan kegagalan acak
Banyak tim keliru menangani flaky test dengan mengizinkan rerun otomatis tanpa jejak. Hasilnya, sinyal kualitas hilang dan regresi pelan-pelan dianggap normal. Workflow yang lebih sehat:
1. Bedakan kegagalan deterministik dan flaky
Saat test gagal di CI, simpan hasil eksekusi, log, dan konteks runtime. Jika test lulus saat rerun, tandai sebagai suspected flaky, bukan dianggap sukses biasa.
2. Buat jalur pelaporan khusus flaky test
Contohnya:
- label otomatis pada PR atau issue,
- dashboard jumlah flaky per suite,
- notifikasi ke pemilik modul.
Tujuannya agar flaky test terlihat sebagai utang teknis aktif, bukan noise yang diterima begitu saja.
3. Izinkan rerun terbatas, tetapi jangan sembunyikan status
Rerun bisa berguna untuk mengurangi blokir sementara, tetapi hasil akhir tetap harus menunjukkan bahwa test pernah gagal. Dengan begitu, tim tetap terdorong memperbaiki akar penyebab.
4. Jalankan subset async sensitif secara lebih intensif
Untuk modul yang rawan concurrency, jalankan test kritis beberapa kali pada pipeline khusus atau jadwal berkala. Bukan untuk “mencari keberuntungan”, tetapi untuk memperbesar peluang race yang masih tersisa sambil tetap mempertahankan test deterministik sebagai prioritas utama.
5. Karantina hanya sebagai langkah sementara
Jika test benar-benar mengganggu delivery, karantina boleh dipakai sementara. Namun tetapkan:
- pemilik perbaikan,
- batas waktu,
- issue yang melacak akar masalah,
- kriteria kapan test dikembalikan ke jalur wajib.
Prinsip pentingnya: CI boleh membantu mendeteksi flaky test, tetapi tidak boleh membuat flaky test terlihat normal.
Pola implementasi yang langsung bisa dipakai tim backend
- Standarkan helper test async: sediakan utilitas internal untuk timeout, pembuatan resource unik, dan shutdown task.
- Buat boundary I/O yang mudah dimock: jangan biarkan logika retry atau timeout terkunci pada client konkret.
- Gunakan sinyal eksplisit antar task: channel/checkpoint lebih baik daripada sleep.
- Wajibkan test cancellation untuk operasi yang memegang lock, membuka stream, atau memicu side effect bertahap.
- Pastikan cleanup dapat diverifikasi: misalnya lewat counter, status state, atau event finalisasi.
- Uji invariant concurrency, bukan sekadar output akhir pada jalur sukses.
Kesalahan yang paling sering memicu regresi
- menulis test berdasarkan timing mesin lokal,
- menganggap retry sudah aman tanpa memeriksa duplikasi side effect,
- tidak menguji cancellation di tengah operasi,
- membiarkan task hasil
spawnhidup tanpa lifecycle yang jelas, - menggabungkan terlalu banyak dependency nyata dalam satu test,
- menggunakan global state yang tidak di-reset antar test.
Penutup
Strategi test async Rust yang kuat berangkat dari pemahaman bahwa future, executor, dan scheduling membuat banyak bug tersembunyi tidak muncul secara konsisten. Karena itu, tujuan utama test bukan sekadar mengeksekusi fungsi async, melainkan mengendalikan interaksi antartask, waktu, I/O, dan cancellation dengan cukup ketat agar hasilnya deterministik.
Untuk tim backend, langkah paling berdampak biasanya sederhana tetapi disiplin: kurangi sleep, tambah timeout eksplisit, isolasi shared state, mock I/O untuk skenario gagal, uji cancellation dan retry, lalu jadikan flaky test sebagai sinyal yang harus ditindak, bukan kebisingan yang dibiasakan. Dengan pendekatan ini, suite test async tidak hanya lebih stabil di CI, tetapi juga lebih efektif mencegah regresi yang biasanya paling mahal saat lolos ke produksi.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!