Go synctest:徹底解決並發測試的痛點

Go 語言以 goroutine 和 channel 聞名,併發測試場景卻常常讓人頭痛:
sleep 不夠會 fail,sleep 太久拖慢 CI,偶發錯誤難以重現。Go 1.24 開始,提供了一個令人振奮的實驗性測試新功能:
synctest。
讓這一切成為過去!!預計將在 8 月釋出的 Go 1.25 正式釋出。
為什麼傳統並發測試這麼難寫?
讓我們看一個常見的例子:
假設你要測試一個 goroutine 工作是否如期完成。
func TestWorker(t *testing.T) {
done := make(chan struct{})
go func() {
// do some work
time.Sleep(100 * time.Millisecond)
close(done)
}()
time.Sleep(150 * time.Millisecond) // 等 goroutine 做完
select {
case <-done:
// ok
default:
t.Fatal("worker 未完成")
}
}
問題在哪?
你只能靠 sleep「猜」goroutine 完成的時機
sleep 太短測試會 fail,太長又浪費時間
在 CI 跑多次還是可能偶爾 fail
很難精確同步 goroutine 狀態
testing/synctest:用「泡泡」模型徹底解決同步問題
synctest 的核心在於「泡泡」(bubble)與「穩定阻塞」(durably blocked)同步模型。
什麼是「泡泡」?
用
synctest.Run包起來的程式碼和其 goroutine,會被放進一個「泡泡」泡泡內的 goroutine 只能互相影響,和外部世界隔離
泡泡追蹤所有在裡面建立的 channel、timer、WaitGroup 等同步物件
什麼是「穩定阻塞」?
當泡泡裡所有 goroutine 都卡住,而且只能被泡泡內其他 goroutine 喚醒,這時就叫做「穩定阻塞」
這時呼叫
synctest.Wait()會立刻返回若泡泡內沒有人能再被解鎖,代表死鎖,
Run會 panic若有 timer 等待,fake clock 會自動快轉到下一個事件
實戰範例:測試 context.AfterFunc
傳統寫法
func TestAfterFunc(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
calledCh := make(chan struct{})
context.AfterFunc(ctx, func() { close(calledCh) })
// 先確認還沒 cancel 前不會被呼叫
select {
case <-calledCh:
t.Fatal("AfterFunc 在 cancel 前就被呼叫")
case <-time.After(10 * time.Millisecond):
// OK
}
cancel()
// 再確認 cancel 後一定會被呼叫
select {
case <-calledCh:
// OK
case <-time.After(10 * time.Millisecond):
t.Fatal("AfterFunc 沒有在 cancel 後被呼叫")
}
}
痛點:
10ms 對快機器來說很長,對慢機器可能不夠,導致測試「慢」又「不穩」
sleep 長一點雖然穩,但測試就變慢
sleep 短一點測試快,但偶爾就 fail
用 synctest 重寫
import (
"context"
"testing/synctest"
"testing"
)
func TestAfterFunc(t *testing.T) {
synctest.Run(func() {
ctx, cancel := context.WithCancel(context.Background())
called := false
context.AfterFunc(ctx, func() { called = true })
synctest.Wait() // 等到所有 goroutine 都卡住
if called {
t.Fatal("AfterFunc 在 cancel 前就被呼叫")
}
cancel()
synctest.Wait() // 再等一次
if !called {
t.Fatal("AfterFunc 沒有在 cancel 後被呼叫")
}
})
}
優點:
不用 sleep,測試又快又穩
不用 channel,直接用變數就好,因為 synctest.Wait 會保證沒有 data race
測試邏輯更清楚
時間相關的並發測試
synctest 會自動用 fake clock 控制時間,只要泡泡裡的 goroutine 都 block 住,時間就會自動快轉到下一個可解鎖事件。
範例:測試 context.WithTimeout
import (
"context"
"testing/synctest"
"testing"
"time"
)
func TestWithTimeout(t *testing.T) {
synctest.Run(func() {
const timeout = 5 * time.Second
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
time.Sleep(timeout - time.Nanosecond)
synctest.Wait()
if ctx.Err() != nil {
t.Fatalf("timeout 前 ctx.Err() = %v,預期為 nil", ctx.Err())
}
time.Sleep(time.Nanosecond)
synctest.Wait()
if ctx.Err() != context.DeadlineExceeded {
t.Fatalf("timeout 後 ctx.Err() = %v,預期為 DeadlineExceeded", ctx.Err())
}
})
}
重點:
寫法和一般測試一樣,但不用擔心 sleep 真的拖慢測試
synctest.Wait 會讓 fake clock 自動前進,測試又快又穩
泡泡同步模型的細節
哪些阻塞會被 synctest 當作「穩定阻塞」?
只有下列情況會被判斷為「穩定阻塞」:
nil channel 上的 send/receive
泡泡內建立的 channel 上的 send/receive(且已經 block)
select 裡所有 case 都是上述阻塞
time.Sleep
sync.Cond.Wait
sync.WaitGroup.Wait
哪些不算?
sync.Mutex(因為可能被泡泡外 goroutine 解鎖)
泡泡外建立的 channel
外部 I/O(例如網路、檔案)
- 目前 synctest 只支援泡泡內建立的同步物件,真實網路、檔案等外部 I/O 仍無法 fake。
任何可能被泡泡外事件喚醒的阻塞
Channel 的特別規則
只有「泡泡內建立」的 channel,才會被 synctest 追蹤
如果你在泡泡外操作泡泡內的 channel,會 panic
測試網路程式怎麼辦?
真實網路 I/O 不被 synctest 控制,建議用 net.Pipe 這種 in-memory fake network 來測試,這樣 synctest 才能正確判斷所有 goroutine 是否真的都卡住。
傳統 vs synctest
實戰範例:Net I/O
測試 HTTP client “Expect: 100-continue” 行為
package v6
import (
"bufio"
"context"
"io"
"net"
"net/http"
"strings"
"testing"
"time"
)
func TestHTTPExpect100Continue_NoSyncTest(t *testing.T) {
srvConn, cliConn := net.Pipe()
defer srvConn.Close()
defer cliConn.Close()
tr := &http.Transport{
DialContext: func(ctx context.Context, network, address string) (net.Conn, error) {
return cliConn, nil
},
ExpectContinueTimeout: 500 * time.Millisecond, // 設短一點
}
body := "request body"
done := make(chan struct{})
// 啟動 client 發 request
go func() {
req, _ := http.NewRequest("PUT", "http://fake.tld/", strings.NewReader(body))
req.Header.Set("Expect", "100-continue")
resp, err := tr.RoundTrip(req)
if err != nil {
t.Errorf("RoundTrip: unexpected error %v", err)
} else {
resp.Body.Close()
}
close(done)
}()
// server 端讀 request headers
req, err := http.ReadRequest(bufio.NewReader(srvConn))
if err != nil {
t.Fatalf("ReadRequest: %v", err)
}
// 啟動 goroutine 讀取 body
var gotBody strings.Builder
bodyDone := make(chan struct{})
go func() {
io.Copy(&gotBody, req.Body)
close(bodyDone)
}()
// 等 100ms,確認 body 尚未被送出
time.Sleep(100 * time.Millisecond)
if got := gotBody.String(); got != "" {
t.Fatalf("before 100 Continue, got body: %q", got)
}
// server 回 100 Continue
srvConn.Write([]byte("HTTP/1.1 100 Continue\r\n\r\n"))
// 最多等 200ms,body 應該會到
select {
case <-bodyDone:
// 確認 body
if got := gotBody.String(); got != body {
t.Fatalf("after 100 Continue, got body %q, want %q", got, body)
}
case <-time.After(200 * time.Millisecond):
t.Fatal("timed out waiting for client to send body")
}
// server 回 200 OK 結束
srvConn.Write([]byte("HTTP/1.1 200 OK\r\n\r\n"))
// 等 client 結束
select {
case <-done:
case <-time.After(200 * time.Millisecond):
t.Fatal("timed out waiting for client to finish")
}
}
> go test -run TestHTTPExpect100Continue_NoSyncTest -v
=== RUN TestHTTPExpect100Continue_NoSyncTest
--- PASS: TestHTTPExpect100Continue_NoSyncTest (0.10s)
PASS
ok github.com/quii/learn-go-with-tests/sync/v6 0.105s
改用 testing/synctest
用
net.Pipe可以 fake network,讓 synctest 能完全控制測試環境。- 建立一對 in-memory 連線(
srvConn,cliConn),不會碰到真實網路 I/O。
- 建立一對 in-memory 連線(
不要用真實的 TCP/UDP,否則 synctest 控制不了外部事件。
這種方式可以 deterministic 地測各種網路 protocol 行為。
package v7
import (
"bufio"
"context"
"io"
"net"
"net/http"
"strings"
"testing"
"testing/synctest"
)
func TestHTTPExpect100Continue(t *testing.T) {
synctest.Run(func() {
srvConn, cliConn := net.Pipe()
defer srvConn.Close()
defer cliConn.Close()
tr := &http.Transport{
DialContext: func(ctx context.Context, network, address string) (net.Conn, error) {
return cliConn, nil
},
// 這個 timeout 不會真的等 5 秒,因為 synctest fake time
ExpectContinueTimeout: 5 * 1e9, // 5 秒
}
body := "request body"
// 啟動 client 發 request
go func() {
req, _ := http.NewRequest("PUT", "http://fake.tld/", strings.NewReader(body))
req.Header.Set("Expect", "100-continue")
resp, err := tr.RoundTrip(req)
if err != nil {
t.Errorf("RoundTrip: unexpected error %v", err)
} else {
resp.Body.Close()
}
}()
// server 端讀 request headers
req, err := http.ReadRequest(bufio.NewReader(srvConn))
if err != nil {
t.Fatalf("ReadRequest: %v", err)
}
// 啟動 goroutine 讀取 body
var gotBody strings.Builder
go io.Copy(&gotBody, req.Body)
// 等 bubble 裡 goroutine 都 block
synctest.Wait()
// 應該還沒收到 body
if got := gotBody.String(); got != "" {
t.Fatalf("before 100 Continue, got body: %q", got)
}
// server 回 100 Continue
srvConn.Write([]byte("HTTP/1.1 100 Continue\r\n\r\n"))
synctest.Wait()
// 現在應該收到完整 body
if got := gotBody.String(); got != body {
t.Fatalf("after 100 Continue, got body %q, want %q", got, body)
}
// server 回 200 OK 結束
srvConn.Write([]byte("HTTP/1.1 200 OK\r\n\r\n"))
})
}
> GOEXPERIMENT=synctest go test -run TestHTTPExpect100Continue -v
=== RUN TestHTTPExpect100Continue
--- PASS: TestHTTPExpect100Continue (0.00s)
PASS
ok github.com/quii/learn-go-with-tests/sync/v7 0.004s
實戰範例:EventMonitor 併發測試
假設你有一個 EventMonitor,要測試它能正確處理多 goroutine 並發通知。以及一些其他場景。這種 pattern 也常見於 scheduler、watcher、poller 等服務元件,現在你可以放心用 synctest 測它
package monitor
import (
"context"
"time"
)
// EventMonitor : 簡化版本
type EventMonitor struct {
notificationChan <-chan string
ticker *time.Ticker
checkFunc func(context.Context)
interval time.Duration
ctx context.Context
cancel context.CancelFunc
ProcessNotification func(string)
}
// NewTokenMonitor: constructor
func New(notificationChan <-chan string) *EventMonitor {
ctx, cancel := context.WithCancel(context.Background())
return &EventMonitor{
notificationChan: notificationChan,
interval: 1 * time.Second,
ctx: ctx,
cancel: cancel,
ProcessNotification: func(string) {
// 預設實作,不做任何事
},
}
}
// SetCheckFunc : set check function
func (tm *EventMonitor) SetCheckFunc(fn func(context.Context)) {
tm.checkFunc = fn
}
// SetInterval : set scan interval
func (tm *EventMonitor) SetInterval(interval time.Duration) {
tm.interval = interval
if tm.ticker != nil {
tm.ticker.Reset(interval)
}
}
// Run : 啟動 monitor instance
func (tm *EventMonitor) Run() {
tm.ticker = time.NewTicker(tm.interval)
for {
select {
case msg, ok := <-tm.notificationChan:
if !ok {
return // since channel is closed and then return the process
}
go tm.ProcessNotification(msg)
case <-tm.ticker.C:
if tm.checkFunc != nil {
go tm.checkFunc(tm.ctx)
}
case <-tm.ctx.Done():
return // since context is cancled and then return
}
}
}
// Stop : stop monitor
func (tm *EventMonitor) Stop() {
if tm.ticker != nil {
tm.ticker.Stop()
}
tm.cancel()
}
package monitor
import (
"context"
"fmt"
"sync/atomic"
"testing"
"time"
)
// TestEventMonitor_Concurrency 測試 EventMonitor 處理大量並發通知的能力
// 模擬多個 goroutine 同時發送通知,確保所有通知都能被正確處理
func TestEventMonitor_Concurrency(t *testing.T) {
// Arrange
notificationChan := make(chan string, 100)
tm := New(notificationChan)
var processedCount atomic.Int32
// 設置處理通知的函數來計數
// 每當收到一個通知時,計數器加一
tm.ProcessNotification = func(msg string) {
processedCount.Add(1)
}
// Act
go tm.Run()
// 啟動 10 個 goroutine,每個發送 10 個通知
// 總共發送 100 個通知,測試並發處理能力
for i := 0; i < 10; i++ {
go func(id int) {
for j := 0; j < 10; j++ {
notificationChan <- fmt.Sprintf("n-%d-%d", id, j)
}
}(i)
}
// 等待足夠時間讓通知被處理
time.Sleep(500 * time.Millisecond)
// Assert
if processedCount.Load() != 100 {
t.Errorf("通知處理數量不符")
}
tm.Stop()
}
// TestEventMonitor_TimerTrigger 測試定時器觸發功能
// 驗證 checkFunc 是否會按照設定的時間間隔被調用
func TestEventMonitor_TimerTrigger(t *testing.T) {
// Arrange
notificationChan := make(chan string, 10)
tm := New(notificationChan)
// 設置短間隔時間 (100ms),以便快速測試
interval := 100 * time.Millisecond
tm.SetInterval(interval)
// 計數器,記錄 checkFunc 被調用的次數
var checkCount atomic.Int32
// 設置檢查函數,每次被調用時計數器加一
tm.SetCheckFunc(func(ctx context.Context) {
checkCount.Add(1)
})
// Act
go tm.Run()
// 等待約 550ms,理論上 checkFunc 應該被調用約 5 次
time.Sleep(550 * time.Millisecond)
// Assert
// 檢查調用次數是否在預期範圍內
// 由於計時器精確度和系統負載可能導致誤差,允許結果在 4-6 次之間
count := checkCount.Load()
if count < 4 || count > 6 {
t.Errorf("預期 checkFunc 應被觸發約 5 次,實際觸發 %d 次", count)
}
// 停止監控器
tm.Stop()
}
// TestEventMonitor_ChannelClose 測試通知通道關閉時的行為
// 驗證當通知通道關閉時,監控器是否會優雅退出
func TestEventMonitor_ChannelClose(t *testing.T) {
// Arrange
notificationChan := make(chan string, 10)
tm := New(notificationChan)
// 設置通知處理函數
var processedCount atomic.Int32
tm.ProcessNotification = func(msg string) {
processedCount.Add(1)
}
// 使用 done 通道來監控 Run 方法是否結束
done := make(chan struct{})
go func() {
tm.Run() // 當通知通道關閉時,Run 應該自動返回
close(done) // 通知測試 Run 已結束
}()
// 發送兩個測試通知
notificationChan <- "test1"
notificationChan <- "test2"
// 等待通知被處理
time.Sleep(100 * time.Millisecond)
// Act
// 關閉通知通道,這應該導致 Run 方法退出
close(notificationChan)
// 等待 Run 方法退出,最多等待 500ms
select {
case <-done:
// 成功,monitor 已停止運行
case <-time.After(500 * time.Millisecond):
t.Error("通知通道關閉後,monitor 未能停止運行")
}
// Assert
// 確認只有兩個通知被處理
if processedCount.Load() != 2 {
t.Errorf("預期處理 2 個通知,實際處理 %d 個", processedCount.Load())
}
}
// TestEventMonitor_SetInterval 測試動態調整間隔時間的功能
// 驗證 SetInterval 方法是否能有效地改變檢查函數的調用頻率
func TestEventMonitor_SetInterval(t *testing.T) {
// Arrange
notificationChan := make(chan string, 10)
tm := New(notificationChan)
// 設置檢查函數
var checkCount atomic.Int32
tm.SetCheckFunc(func(ctx context.Context) {
checkCount.Add(1)
})
// 初始設置為較長間隔 (1秒)
tm.SetInterval(1 * time.Second)
// Act
go tm.Run()
// 等待短時間,由於間隔長,應該不會觸發多次
time.Sleep(250 * time.Millisecond)
initialCount := checkCount.Load() // 記錄初始計數
// 動態修改為較短間隔 (100ms)
tm.SetInterval(100 * time.Millisecond)
// 再等待足夠時間,讓短間隔產生多次觸發
time.Sleep(550 * time.Millisecond) // 應該至少觸發 4-5 次
// Assert
// 檢查觸發次數是否明顯增加
finalCount := checkCount.Load()
if finalCount-initialCount < 4 {
t.Errorf("更改間隔後,預期至少增加 4 次觸發,實際增加 %d 次", finalCount-initialCount)
}
// 停止監控器
tm.Stop()
}
> go test -run ./... -v
=== RUN TestEventMonitor_Concurrency
--- PASS: TestEventMonitor_Concurrency (0.50s)
=== RUN TestEventMonitor_TimerTrigger
--- PASS: TestEventMonitor_TimerTrigger (0.55s)
=== RUN TestEventMonitor_ChannelClose
--- PASS: TestEventMonitor_ChannelClose (0.10s)
=== RUN TestEventMonitor_SetInterval
--- PASS: TestEventMonitor_SetInterval (0.80s)
PASS
ok github.com/quii/learn-go-with-tests/sync/monitor 1.956s
改成用 testing/synctest
package monitor
import (
"context"
"fmt"
"sync/atomic"
"testing"
"testing/synctest"
"time"
)
// TestEventMonitor_Concurrency 測試 EventMonitor 處理大量並發通知的能力
// 模擬多個 goroutine 同時發送通知,確保所有通知都能被正確處理
func TestEventMonitor_Concurrency(t *testing.T) {
synctest.Run(func() {
// Arrange
notificationChan := make(chan string, 100)
tm := New(notificationChan)
var processedCount atomic.Int32
// 設置處理通知的函數來計數
// 每當收到一個通知時,計數器加一
tm.ProcessNotification = func(msg string) {
processedCount.Add(1)
}
// Act
go tm.Run()
// 啟動 10 個 goroutine,每個發送 10 個通知
// 總共發送 100 個通知,測試並發處理能力
for i := 0; i < 10; i++ {
go func(id int) {
for j := 0; j < 10; j++ {
notificationChan <- fmt.Sprintf("n-%d-%d", id, j)
}
}(i)
}
// 等待足夠時間讓通知被處理
time.Sleep(500 * time.Millisecond)
synctest.Wait()
// Assert
if processedCount.Load() != 100 {
t.Errorf("通知處理數量不符")
}
tm.Stop()
})
}
// TestEventMonitor_TimerTrigger 測試定時器觸發功能
// 驗證 checkFunc 是否會按照設定的時間間隔被調用
func TestEventMonitor_TimerTrigger(t *testing.T) {
synctest.Run(func() {
// Arrange
notificationChan := make(chan string, 10)
tm := New(notificationChan)
// 設置短間隔時間 (100ms),以便快速測試
interval := 100 * time.Millisecond
tm.SetInterval(interval)
// 計數器,記錄 checkFunc 被調用的次數
var checkCount atomic.Int32
// 設置檢查函數,每次被調用時計數器加一
tm.SetCheckFunc(func(ctx context.Context) {
checkCount.Add(1)
})
// Act
go tm.Run()
// 等待約 550ms,理論上 checkFunc 應該被調用約 5 次
time.Sleep(550 * time.Millisecond)
synctest.Wait()
// Assert
// 檢查調用次數是否在預期範圍內
// 由於計時器精確度和系統負載可能導致誤差,允許結果在 4-6 次之間
count := checkCount.Load()
if count < 4 || count > 6 {
t.Errorf("預期 checkFunc 應被觸發約 5 次,實際觸發 %d 次", count)
}
// 停止監控器
tm.Stop()
})
}
// TestEventMonitor_ChannelClose 測試通知通道關閉時的行為
// 驗證當通知通道關閉時,監控器是否會優雅退出
func TestEventMonitor_ChannelClose(t *testing.T) {
synctest.Run(func() {
// Arrange
notificationChan := make(chan string, 10)
tm := New(notificationChan)
// 設置通知處理函數
var processedCount atomic.Int32
tm.ProcessNotification = func(msg string) {
processedCount.Add(1)
}
// 使用 done 通道來監控 Run 方法是否結束
done := make(chan struct{})
go func() {
tm.Run() // 當通知通道關閉時,Run 應該自動返回
close(done) // 通知測試 Run 已結束
}()
// 發送兩個測試通知
notificationChan <- "test1"
notificationChan <- "test2"
// 等待通知被處理
time.Sleep(100 * time.Millisecond)
synctest.Wait()
// Act
// 關閉通知通道,這應該導致 Run 方法退出
close(notificationChan)
// 等待 Run 方法退出,最多等待 500ms
select {
case <-done:
// 成功,monitor 已停止運行
case <-time.After(500 * time.Millisecond):
t.Error("通知通道關閉後,monitor 未能停止運行")
}
// Assert
// 確認只有兩個通知被處理
if processedCount.Load() != 2 {
t.Errorf("預期處理 2 個通知,實際處理 %d 個", processedCount.Load())
}
tm.Stop()
})
}
// TestEventMonitor_SetInterval 測試動態調整間隔時間的功能
// 驗證 SetInterval 方法是否能有效地改變檢查函數的調用頻率
func TestEventMonitor_SetInterval(t *testing.T) {
synctest.Run(func() {
// Arrange
notificationChan := make(chan string, 10)
tm := New(notificationChan)
// 設置檢查函數
var checkCount atomic.Int32
tm.SetCheckFunc(func(ctx context.Context) {
checkCount.Add(1)
})
// 初始設置為較長間隔 (1秒)
tm.SetInterval(1 * time.Second)
// Act
go tm.Run()
// 等待短時間,由於間隔長,應該不會觸發多次
time.Sleep(250 * time.Millisecond)
synctest.Wait()
initialCount := checkCount.Load() // 記錄初始計數
// 動態修改為較短間隔 (100ms)
tm.SetInterval(100 * time.Millisecond)
// 再等待足夠時間,讓短間隔產生多次觸發
time.Sleep(550 * time.Millisecond) // 應該至少觸發 4-5 次
synctest.Wait()
// Assert
// 檢查觸發次數是否明顯增加
finalCount := checkCount.Load()
if finalCount-initialCount < 4 {
t.Errorf("更改間隔後,預期至少增加 4 次觸發,實際增加 %d 次", finalCount-initialCount)
}
// 停止監控器
tm.Stop()
})
}
> GOEXPERIMENT=synctest go test -run ./... -v
=== RUN TestEventMonitor_Concurrency
--- PASS: TestEventMonitor_Concurrency (0.00s)
=== RUN TestEventMonitor_TimerTrigger
--- PASS: TestEventMonitor_TimerTrigger (0.00s)
=== RUN TestEventMonitor_ChannelClose
--- PASS: TestEventMonitor_ChannelClose (0.00s)
=== RUN TestEventMonitor_SetInterval
--- PASS: TestEventMonitor_SetInterval (0.00s)
PASS
ok github.com/quii/learn-go-with-tests/sync/monitor/v2 0.003s
不難看見測試都是瞬間完成,這樣測試就能脫離時間的不確定性了,變得更加穩健。
總結與心得
Go 1.24 帶來的 testing/synctest,徹底改變了我們寫並發測試的方式。不用再自己實做 mock timer。
也不用再靠 time.Sleep 來猜測 goroutine 進度、不用再擔心 CI 上偶發失敗,
只要把測試放進 synctest 的「泡泡」裡,所有 goroutine、timer、channel 都能被準確追蹤與 fake,
讓你的併發測試又快、又穩、又 deterministic!
這對任何需要測試 timer、channel、goroutine 行為的 Go 專案來說,是一大福音。
無論你在寫 HTTP 協議、事件監控、poller、watcher,甚至自訂同步 primitive,
都可以放心用 synctest 讓測試變得簡單可靠。
小提醒:
synctest 目前仍是實驗性功能,只能 fake 泡泡內的同步物件,外部 I/O 仍需小心。
建議多用 net.Pipe、in-memory fake 等技巧,讓測試完全可控。
最後,推薦大家勇敢升級 Go 1.25,並開始把自己的併發測試搬進 synctest 泡泡裡!
你會發現,寫並發測試再也不可怕,甚至變得很有趣!期待 Go 1.25 正式版本的釋出!
Reference:
[Go blog - Testing concurrent code with testing/synctest](https://go.dev/blog/synctest)






