Skip to main content

Command Palette

Search for a command to run...

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

Updated
10 min read
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。
  • 不要用真實的 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)

546 views

More from this blog

Claude Code 監控秘錄:OpenTelemetry(OTel/OTLP)實戰指南

稟告主公:此乃司馬懿進呈之兵書,詳解如何以 OpenTelemetry 陣法,令臥龍神算之一舉一動盡在掌握,知糧草消耗、察兵器效能、辨戰報異常,使主公運籌帷幄於大帳之中。 為何需要斥候情報? 司馬懿稟告主公: 臥龍神算(Claude Code)乃當世利器,然若無斥候回報,主公便如蒙眼行軍——兵器耗損幾何、糧草消費幾許、哪路斥候出了差錯,一概不知。臣以為,此乃兵家大忌。 無情報之弊,有四: 軍

Feb 19, 202610 min read163
Claude Code 監控秘錄:OpenTelemetry(OTel/OTLP)實戰指南

工程師的 Claude Code 實戰指南:從零開始到高效開發

工程師的 Claude Code 實戰指南:從零開始到高效開發 本文整合 Anthropic 官方 Best Practices 與社群實戰 Tips,帶你由淺入深掌握 Claude Code。 什麼是 Claude Code?為什麼值得學? 如果你還在用「複製程式碼貼到 ChatGPT,再複製答案貼回去」的工作流程,Claude Code 會讓你大開眼界。 Claude Code 是 Anthropic 推出的命令列工具,它直接活在你的 terminal 裡,能夠讀懂你的整個 codeb...

Feb 18, 20265 min read75
工程師的 Claude Code 實戰指南:從零開始到高效開發

System Design Interview Ch 12 Digital Wallet

確立問題與設計範疇 角色對話內容 面試者我們應該只關注兩個數位錢包之間的餘額轉帳操作嗎?我們是否需要擔心其他功能? 面試官讓我們只關注餘額轉帳操作。 面試者該系統需要支援多少 TPS(每秒交易次數)? 面試官讓我們假設是 1,000,000 TPS (每秒 100 萬次交易)。 面試者數位錢包對正確性有嚴格的要求。我們可以假設事務保證 就足夠了嗎? 面試官聽起來不錯。 面試者我們需要證明正確性嗎? 面試官這是一個很好的問題。正確性(Correctness)通常只有在交...

Feb 2, 202610 min read190
System Design Interview Ch 12 Digital Wallet

Claude Code 利用 Event-Driven Hooks 打造自動化開發大腦

在現代 AI 輔助開發中,我們不僅需要 AI 寫程式,更需要它懂規則、記性好,並且能自動處理那些繁瑣的雜事。透過 Claude Code Hooks 機制,我們可以介入 AI 的思考與執行迴圈,實現真正的「人機協作自動化」。 一、 動機與痛點:為什麼你需要介入 AI 的生命週期? 在預設狀態下,Claude Code 雖然強大,但它是「被動」且「無狀態」的,這導致了開發者常遇到以下痛點: 記憶重置 (Session Amnesia): 痛點:每次重啟終端機,AI 就像失憶一樣。 解法:你...

Jan 24, 20266 min read457
Claude Code 利用 Event-Driven Hooks 打造自動化開發大腦
M

MicroFIRE

71 posts