Golang 1.25 testing/synctest 初體驗:告別在測試中寫 time.Sleep 的日子
前言 在我們目前的應用程式架構中,由於高度與 Kubernetes 耦合,服務啟動與運作期間需要頻繁地去讀取 K8s 中的 ConfigMap。為了達成配置熱更新(Hot Reload),我們引入了 Kubernetes client-go 中的 informers 機制來監聽 ConfigMap 的 CRUD 事件。 雖然 K8s 官方提供了 fake client 讓我們能測試 informers 的邏輯,但在 Service Code 的層級,我們往往需要封裝一層更適合業務邏輯的 ConfigWatcher。Golang 引以為傲的輕量級 Goroutine 與 Channel 搭配非常適合用來處理這種非同步的事件傳遞。 然而,一旦涉及到 Goroutine 的非同步測試,「時間」往往就成了最大的敵人。 遇到的問題:不穩定的測試與魔法數字 為了模擬 ConfigMap 的變更通知,我們定義了一個 ConfigMapWatcher 介面與對應的 Event 結構: const ( ConfigMapUpdateEventTypeAdded ConfigMapUpdateEventType = iota ConfigMapUpdateEventTypeModified ConfigMapUpdateEventTypeDeleted ) type ConfigMapUpdateEvent struct { Name string Type ConfigMapUpdateEventType Value map[string]string } type ConfigMapWatcher interface { Watch(ctx context.Context, eventCh chan<- ConfigMapUpdateEvent) error } 接著,我們很自然地在 testing 中實作了一個 fake 物件來模擬事件發送: type fakeConfigMapWatcher struct { injectCh chan ConfigMapUpdateEvent watchErr error watchOnce sync.Once } func newFakeConfigMapWatcher() *fakeConfigMapWatcher { return &fakeConfigMapWatcher{ injectCh: make(chan ConfigMapUpdateEvent), } } func (f *fakeConfigMapWatcher) sendEvent(event ConfigMapUpdateEvent) { f.injectCh <- event } func (f *fakeConfigMapWatcher) Watch(ctx context.Context, eventCh chan<- ConfigMapUpdateEvent) error { if f.watchErr != nil { return f.watchErr } f.watchOnce.Do(func() { go func() { for { select { case <-ctx.Done(): return case e := <-f.injectCh: eventCh <- e } } }() }) return nil } 問題來了,當我們在寫單元測試時,呼叫 sendEvent 將事件送入 channel 後,消費者端(也就是我們的業務邏輯 Goroutine)並不會「立刻」收到並處理完成。為了確保 assert 斷言執行時,業務邏輯已經跑完了,我們被迫在測試中加入 time.Sleep: ...