5.6 並發行為測試
並發測試的核心目標是驗證可觀察的同步行為,而不是猜測 goroutine 的執行順序。Go 的 goroutine 由 scheduler 安排,測試應該用 channel、context、WaitGroup 與 timeout 表達「什麼結果必須發生」。
並發測試應等待明確訊號
並發程式的核心限制是執行順序不穩定。測試如果假設某個 goroutine 一定先跑,通常會變成偶發失敗。
1func sendAsync(ch chan<- string) {
2 go func() {
3 ch <- "ready"
4 }()
5}測試不應該在呼叫後立刻假設資料已經送出,而應該等待明確訊號。
1func TestSendAsync(t *testing.T) {
2 ch := make(chan string, 1)
3
4 sendAsync(ch)
5
6 select {
7 case got := <-ch:
8 if got != "ready" {
9 t.Fatalf("message = %q, want %q", got, "ready")
10 }
11 case <-time.After(time.Second):
12 t.Fatalf("timeout waiting for message")
13 }
14}select 加 timeout 可以避免測試永久卡住。timeout 不應該用來證明程式正確,只是測試失敗時的保護機制。
channel 測試要驗證傳遞結果
channel 測試的核心問題是資料是否被送到預期位置。測試應該觀察 channel 收到的值,或觀察 channel 關閉後的狀態。
1func Produce(ids []string) <-chan string {
2 out := make(chan string)
3
4 go func() {
5 defer close(out)
6 for _, id := range ids {
7 out <- id
8 }
9 }()
10
11 return out
12}這個函式回傳只讀 channel,呼叫端可以 range 讀取直到 channel 關閉。
1func TestProduce(t *testing.T) {
2 out := Produce([]string{"a", "b"})
3
4 var got []string
5 for id := range out {
6 got = append(got, id)
7 }
8
9 want := []string{"a", "b"}
10 if !reflect.DeepEqual(got, want) {
11 t.Fatalf("Produce() = %#v, want %#v", got, want)
12 }
13}這個測試沒有使用 sleep。channel 關閉就是明確完成訊號,測試可以自然結束。
context 用來測試退出
goroutine 退出測試的核心做法是提供可取消的 context.Context,再等待函式發出完成訊號。沒有退出訊號的 goroutine 很難可靠測試。
1func RunWorker(ctx context.Context, jobs <-chan string, done chan<- struct{}) {
2 defer close(done)
3
4 for {
5 select {
6 case <-ctx.Done():
7 return
8 case <-jobs:
9 // process job
10 }
11 }
12}測試可以取消 context,然後確認 done 被關閉。
1func TestRunWorkerStops(t *testing.T) {
2 ctx, cancel := context.WithCancel(context.Background())
3 jobs := make(chan string)
4 done := make(chan struct{})
5
6 go RunWorker(ctx, jobs, done)
7 cancel()
8
9 select {
10 case <-done:
11 case <-time.After(time.Second):
12 t.Fatalf("worker did not stop")
13 }
14}done channel 是測試與 goroutine 之間的完成協定。若程式沒有這種協定,測試只能猜測 goroutine 是否已經退出。
sync.WaitGroup 適合等待一組工作完成
WaitGroup 的核心用途是等待已知數量的 goroutine 完成。它適合 fan-out 工作、批次處理與測試中需要等多個背景任務結束的情境。
1func ProcessAll(items []string, process func(string)) {
2 var wg sync.WaitGroup
3
4 for _, item := range items {
5 item := item
6 wg.Add(1)
7 go func() {
8 defer wg.Done()
9 process(item)
10 }()
11 }
12
13 wg.Wait()
14}測試可以用 mutex 保護共享 slice,並在函式回傳後檢查結果。
1func TestProcessAll(t *testing.T) {
2 var mu sync.Mutex
3 var got []string
4
5 ProcessAll([]string{"a", "b"}, func(item string) {
6 mu.Lock()
7 defer mu.Unlock()
8 got = append(got, item)
9 })
10
11 sort.Strings(got)
12 want := []string{"a", "b"}
13 if !reflect.DeepEqual(got, want) {
14 t.Fatalf("processed = %#v, want %#v", got, want)
15 }
16}因為 goroutine 執行順序不固定,測試先排序再比較。這表示測試關心「所有項目都有被處理」,不關心處理順序。
race detector 檢查共享狀態
共享狀態測試的核心風險是 data race。Go 提供 race detector,可以在測試時檢查多個 goroutine 是否未同步讀寫同一份資料。
1go test -race ./...-race 會讓測試變慢,但能抓出許多一般斷言看不見的並發錯誤。只要程式有 goroutine 與共享資料,定期跑 race test 就很有價值。
race detector 不是邏輯正確性的完整證明。它能檢查資料競爭,但不能保證事件順序、buffer 策略或 timeout 行為都符合需求;這些仍然要靠明確測試案例。