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

  1. Ganti Akses Direct: Lakukan audit pada basis kode untuk menemukan pemanggilan langsung time.Now() atau time.Since() di dalam domain logic maupun HTTP handler.
  2. 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.
  3. Hindari Abstraksi Berlebih: Cukup sediakan method yang dibutuhkan aplikasi (misal: Now()). Hindari membungkus time.Timer, time.Ticker, atau format fungsi kompleks lainnya jika aplikasi hanya memerlukan referensi waktu saat eksekusi.