Root Cause Flaky Test Berbasis Waktu di CI/CD
Pengujian logika temporal (seperti token expiration, OTP rate limiting, dan session timeout) sering kali lolos di lingkungan lokal, namun gagal secara intermiten di lingkungan CI/CD. Masalah ini berakar pada dua faktor utama:
- Ketergantungan pada Wall-Clock (
time.Now()): Pemanggilan langsung waktu sistem membuat status aplikasi non-deterministik. Perbedaan beberapa milidetik antara eksekusi logika aplikasi dan asersi tes menyebabkan kegagalan acak. - Penggunaan
time.Sleep()dalam Test Suite: Runner CI (seperti GitHub Actions atau GitLab CI) umumnya berjalan di lingkungan kontainer tervirtualisasi dengan alokasi CPU bersama (shared CPU). Ketika terjadi CPU throttling atau context-switch lag,time.Sleep(1 * time.Second)dapat tertunda hingga 1.2 detik, merusak jendela waktu yang diuji dan memperlambat total durasi build CI.
Abstraksi Clock: Minimal dan Thread-Safe
Solusi standar untuk mengisolasi waktu adalah dengan mendefinisikan interface clock. Desain ini memungkinkan aplikasi menggunakan sistem waktu nyata saat runtime produksi, dan manipulasi waktu instan tanpa jeda saat tahap unit test.
package clock
import (
"sync"
"time"
)
type Clock interface {
Now() time.Time
}
type RealClock struct{}
func (RealClock) Now() time.Time {
return time.Now()
}
type MockClock struct {
mu sync.RWMutex
now time.Time
}
func NewMockClock(initial time.Time) *MockClock {
return &MockClock{now: initial}
}
func (m *MockClock) Now() time.Time {
m.mu.RLock()
defer m.mu.RUnlock()
return m.now
}
func (m *MockClock) Advance(d time.Duration) {
m.mu.Lock()
defer m.mu.Unlock()
m.now = m.now.Add(d)
}
func (m *MockClock) Set(t time.Time) {
m.mu.Lock()
defer m.mu.Unlock()
m.now = t
}
Implementasi pada Handler Go Fiber
Alih-alih menyuntikkan clock ke dalam fiber.Ctx.Locals yang membutuhkan alokasi memori berulang pada setiap siklus request, injeksikan interface Clock langsung ke struct dependensi handler. Metode ini memberikan performa zero-allocation saat runtime.
package handler
import (
"sync"
"time"
"yourproject/clock"
"github.com/gofiber/fiber/v2"
)
type OTPHandler struct {
clock clock.Clock
cooldown time.Duration
mu sync.Mutex
lastSent map[string]time.Time
}
func NewOTPHandler(c clock.Clock, cooldown time.Duration) *OTPHandler {
if c == nil {
c = clock.RealClock{}
}
return &OTPHandler{
clock: c,
cooldown: cooldown,
lastSent: make(map[string]time.Time),
}
}
func (h *OTPHandler) RequestOTP(c *fiber.Ctx) error {
phone := c.Query("phone")
if phone == "" {
return c.Status(fiber.StatusBadRequest).SendString("Phone required")
}
h.mu.Lock()
defer h.mu.Unlock()
now := h.clock.Now()
if last, exists := h.lastSent[phone]; exists {
if now.Sub(last) < h.cooldown {
return c.Status(fiber.StatusTooManyRequests).SendString("Cooldown active")
}
}
h.lastSent[phone] = now
return c.Status(fiber.StatusOK).SendString("OTP dispatched")
}
Perbandingan: Test Rapuh vs Test Deterministik
1. Contoh Test Rapuh (Anti-Pattern)
Test berikut mengandalkan jeda waktu nyata melalui time.Sleep. Potensi gagal tinggi saat runner CI mengalami delay penjadwalan OS.
// FLAKY: Bergantung pada wall-clock dan jeda nyata
func TestOTPCooldown_Flaky(t *testing.T) {
app := fiber.New()
h := NewOTPHandler(clock.RealClock{}, 100*time.Millisecond)
app.Post("/otp", h.RequestOTP)
req1, _ := http.NewRequest("POST", "/otp?phone=08123", nil)
resp1, _ := app.Test(req1)
if resp1.StatusCode != http.StatusOK {
t.Fail()
}
// Rapuh: Jika CI CPU throttling > 100ms, kondisi ini terlewati
time.Sleep(50 * time.Millisecond)
req2, _ := http.NewRequest("POST", "/otp?phone=08123", nil)
resp2, _ := app.Test(req2)
if resp2.StatusCode != http.StatusTooManyRequests {
t.Errorf("Expected 429, got %d", resp2.StatusCode)
}
}
2. Contoh Test Deterministik dengan Mock Clock
Waktu dimajukan secara manual melalui mock.Advance() tanpa menunda eksekusi CPU thread sama sekali. Total runtime test berkurang menjadi sub-milidetik.
func TestOTPCooldown_Deterministic(t *testing.T) {
baseTime := time.Date(2025, 1, 1, 10, 0, 0, 0, time.UTC)
mock := clock.NewMockClock(baseTime)
h := NewOTPHandler(mock, 60*time.Second)
app := fiber.New()
app.Post("/otp", h.RequestOTP)
// Step 1: Request inisial
req := httptest.NewRequest("POST", "/otp?phone=08123", nil)
resp, err := app.Test(req)
if err != nil || resp.StatusCode != fiber.StatusOK {
t.Fatalf("Request 1 failed: %v", resp.StatusCode)
}
// Step 2: Cek transisi tepat 59 detik (Masih dalam cooldown)
mock.Advance(59 * time.Second)
resp, _ = app.Test(req)
if resp.StatusCode != fiber.StatusTooManyRequests {
t.Errorf("Detik ke-59: ekspektasi 429, hasil %d", resp.StatusCode)
}
// Step 3: Tepat di detik ke-60 (Batas cooldown kedaluwarsa)
mock.Advance(1 * time.Second)
resp, _ = app.Test(req)
if resp.StatusCode != fiber.StatusOK {
t.Errorf("Detik ke-60: ekspektasi 200, hasil %d", resp.StatusCode)
}
}
Pengujian Edge Case Transisi Batas Waktu
Bug logika waktu sering kali berada pada operator perbandingan (< vs <=). Pengujian deterministik memungkinkan verifikasi status sistem tepat pada boundary millisecond tanpa risiko race condition:
- Sub-detik batas bawah (T = 59.999s): Memastikan status tetap terblokir jika selisih waktu belum genap mencapai durasi target.
- Batas pas (T = 60.000s): Memverifikasi transisi state ke normal tepat saat jendela cooldown berakhir.
Kombinasi app.Test() milik Fiber yang membaca memori secara internal (in-memory test via fasthttp pipeline) bersama Mock Clock menghasilkan eksekusi ratusan test case temporal dalam hitungan milidetik di CI.
Panduan Refactoring Handler Eksisting
- Ganti Akses Direct: Lakukan audit pada basis kode untuk menemukan pemanggilan langsung
time.Now()atautime.Since()di dalam domain logic maupun HTTP handler. - Default Fallback: Selalu terapkan pengecekan nil pada konstruktor handler:
if c == nil { c = clock.RealClock{} }. Hal ini menjaga integritas kode eksisting agar tidak panic jika dipanggil tanpa mock. - Hindari Abstraksi Berlebih: Cukup sediakan method yang dibutuhkan aplikasi (misal:
Now()). Hindari membungkustime.Timer,time.Ticker, atau format fungsi kompleks lainnya jika aplikasi hanya memerlukan referensi waktu saat eksekusi.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!