Menguji Rate Limiter Go Tanpa Menunggu Waktu Nyata
Memutar waktu di test Go lewat field Now yang bisa ditukar: rate limiter teruji tanpa sleep, plus kapan pindah ke testing/synctest.
Ringkasan
Nunggu waktu nyata di test tuh lambat plus flaky, jadi jangan. Triknya gampang: tuker jamnya pake field func() time.Time, terus test tinggal majuin jam manual, tanpa time.Sleep. Kalau kode yang dites tidur sendiri atau punya goroutine, pake testing/synctest yang udah stabil di Go 1.25.
Saya sedang menulis test untuk sebuah rate limiter. Syaratnya lugas: permintaan keempat dari kunci yang sama dalam satu menit harus ditolak, lengkap dengan sisa waktu resetnya. Masalahnya klasik. Untuk membuat hit keempat itu terjadi di test, artinya saya harus menunggu, dan menunggu satu menit nyata di dalam suite yang berjalan terus-menerus bukan pilihan yang masuk akal.
Jalan pintas yang paling sering dicoba adalah time.Sleep. Pernah saya tempuh, dan hasilnya dua-duanya buruk: test jadi lambat, lalu tetap gagal acak di CI yang sedang sibuk karena jeda satu menit pun bisa kurang. Dokumentasi resmi Go merangkum dilema ini dengan jujur untuk kasus serupa: test berbasis jeda itu lambat sekaligus flaky, dan kita tidak bisa membuatnya cepat sekaligus andal [4]. Selama jamnya waktu nyata, kita hanya punya dua pilihan jelek.
Jam yang Bisa Ditukar dari Paket Test
Di commit yang sedang saya bahas, solusinya bukan menambah selingkung, melainkan menukar komponennya: jam. Struct rate limiter punya field Now func() time.Time yang secara default menunjuk ke time.Now. Field ini diekspor sengaja agar test bisa memasang jam sendiri. Sebelumnya field itu privat, dan test duduk di package yang sama. Setelah test pindah ke paket eksternal, satu-satunya pintu adalah yang publik, jadi jam itulah yang ditawarkan lewat pintu.
func TestRateLimiterWindow(t *testing.T) {
now := time.Now()
l := middleware.NewRateLimiter(3, time.Minute)
l.Now = func() time.Time { return now } // jam ditukar dari paket test
for i := 0; i < 3; i++ {
if ok, _ := l.Allow("k"); !ok {
t.Fatalf("hit %d should pass", i+1)
}
}
// hit ke-4 dalam jendela: ditolak, sisa waktu reset ikut dikembalikan
if ok, retryAfter := l.Allow("k"); ok || retryAfter <= 0 {
t.Fatalf("4th hit should be blocked, got ok=%v retry=%v", ok, retryAfter)
}
// kunci lain: bucket-nya sendiri-sendiri
if ok, _ := l.Allow("other"); !ok {
t.Fatal("other key should be independent")
}
// geser jam melewati jendela: kuota kembali penuh
now = now.Add(time.Minute + time.Second)
if ok, _ := l.Allow("k"); !ok {
t.Fatal("hit after window should pass")
}
}
Tidak ada satu pun time.Sleep. Test memajukan jam dengan now.Add(time.Minute + time.Second), lalu semua asersi lama tetap berlaku verbatim: hit keempat ditolak dengan retryAfter positif, kunci lain punya bucket sendiri, dan kuota kembali penuh setelah jendela lewat. Satu jam test berjalan secepat test lain.
Catatan desainnya juga enak dibaca: field func sudah cukup, tanpa perlu interface dengan satu implementasi. Kontraknya cuma satu, yaitu "kapan sekarang", dan itu terbaca dari tanda tangan fungsinya. Pola yang sama muncul di commit-commit sekitarnya: direktori avatar disuntikkan lewat parameter NewService, dan batas panjang field seperti MaxTitleLen diekspor supaya test bisa mengaserti batas persisnya. Semuanya satu keluarga: apa pun yang dibutuhkan test, ditawarkan lewat pintu publik.
Kapan Naik Kelas ke synctest
Pola manual di atas portabel dan nol dependensi, tapi ekosistem Go kini punya jawaban bawaan. Paket testing/synctest resmi stabil di Go 1.25 [2]. Sebelumnya di Go 1.24 paket ini eksperimental di balik GOEXPERIMENT=synctest [3], dengan API lama yang akan dihapus di Go 1.26, jadi kalau sempat membaca contoh lama pemakaian synctest.Run, itu sudah jadat.
Cara kerjanya lewat konsep bubble: fungsi synctest.Test menjalankan test dalam grup goroutine terisolasi. Di dalam bubble, paket time memakai jam palsu milik bubble itu sendiri, waktu awalnya tengah malam UTC 2000-01-01 [1]. Jam hanya melompat ke depan saat semua goroutine di bubble diam menunggu, jadi tidur dua detik selesai sesaat kemudian. Fungsi Wait menunggu sampai seluruh goroutine bubble sedang blocked. Perbatasan pemakaiannya jelas: kalau yang diuji cuma logika waktu di satu alur, field func sudah cukup; kalau kode yang diuji tidur sendiri atau punya goroutine yang harus diorkestrasi, bubble yang menang.
Aturan praktis yang saya pegang dari commit ini: jangan pernah mengejar waktu nyata di test. Tukar jamnya, atau isolasi waktunya. Kode produksi tidak berubah sama sekali di sini, hanya satu field yang naik derajat jadi publik, dan test suite mendapat satu jalur yang cepat sekaligus dapat diandalkan.