並發測試的核心目標是驗證可觀察的同步行為,而不是猜測 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 行為都符合需求;這些仍然要靠明確測試案例。