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

> Go 語言以 goroutine 和 channel 聞名，併發測試場景卻常常讓人頭痛：  
> sleep 不夠會 fail，sleep 太久拖慢 CI，偶發錯誤難以重現。
> 
> **Go 1.24** 開始，提供了一個令人振奮的實驗性測試新功能：`synctest`。  
> 讓這一切成為過去！！預計將在 8 月釋出的 Go 1.25 正式釋出。

## 為什麼傳統並發測試這麼難寫？

讓我們看一個常見的例子：  
假設你要測試一個 goroutine 工作是否如期完成。

```go
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`](http://synctest.Run) 包起來的程式碼和其 goroutine，會被放進一個「泡泡」
    
* 泡泡內的 goroutine 只能互相影響，和外部世界隔離
    
* 泡泡追蹤所有在裡面建立的 channel、timer、WaitGroup 等同步物件
    

### 什麼是「穩定阻塞」？

* 當泡泡裡**所有 goroutine 都卡住**，而且**只能被泡泡內其他 goroutine 喚醒**，這時就叫做「穩定阻塞」
    
* 這時呼叫 `synctest.Wait()` 會立刻返回
    
* 若泡泡內沒有人能再被解鎖，代表死鎖，`Run` 會 panic
    
* 若有 timer 等待，fake clock 會自動快轉到下一個事件
    

## 實戰範例：測試 context.AfterFunc

### 傳統寫法

```go
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 重寫

```go

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

```go
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

```mermaid
flowchart TD
    A[傳統測試] -->|time.Sleep| B(等待 goroutine)
    B --> C{goroutine 結束?}
    C -- No --> B
    C -- Yes --> D[Assert 結果]
    D --> E[測試結束]

    F[synctest 測試] --> G(執行待測程式)
    G --> H(synctest.Wait：等待所有 goroutine 穩定阻塞)
    H --> I[檢查狀態/Assert 結果]
    I --> J[測試結束]
```

## 實戰範例：Net I/O

測試 HTTP client “Expect: 100-continue” 行為

```go
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 行為。
    

```go
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 測它

```go
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()
}
```

```go
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

```go
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](https://go.dev/blog/synctest))
