5.1 時間注入與狀態轉移測試
時間控制測試的核心原則是把「現在」變成可指定輸入。只要程式邏輯依賴目前時間、deadline、timeout、ticker 或過期判斷,測試就不應依賴真實等待。
本章目標
學完本章後,你將能夠:
- 用
now time.Time測試純狀態轉移 - 用
func() time.Time注入長生命週期元件的時間來源 - 用 table-driven test 覆蓋時間邊界
- 把 ticker 排程與單次工作拆開測
- 避免
time.Sleep造成慢且不穩定的測試
【觀察】直接呼叫 time.Now 會讓測試失去控制
時間相關邏輯的核心問題是同一筆資料在不同時間會得到不同結果。若函式內部直接呼叫 time.Now(),測試就無法完整控制輸入。
反模式:
1func Status(job Job) string {
2 if job.FinishedAt != nil {
3 return "completed"
4 }
5 if time.Since(job.StartedAt) > 5*time.Minute {
6 return "idle"
7 }
8 return "active"
9}這個函式看起來簡單,但測試無法指定「現在剛好是開始後 4 分鐘」或「現在剛好跨過 5 分鐘」。測試只能依賴真實時間,結果慢且不穩定。
【判讀】時間是狀態轉移的輸入
時間測試的核心判讀是:如果時間會影響結果,時間就是輸入。把 now 放進函式簽名,會讓狀態轉移規則變得可測。
1type Job struct {
2 StartedAt time.Time
3 FinishedAt *time.Time
4}
5
6func Status(now time.Time, job Job) string {
7 if job.FinishedAt != nil {
8 return "completed"
9 }
10
11 if now.Sub(job.StartedAt) > 5*time.Minute {
12 return "idle"
13 }
14
15 return "active"
16}now 是明確輸入,因此測試可以建立任何時間點。這也讓讀者一眼看出 Status 看的是 Job 與目前時間的關係。
【執行】用 table-driven test 描述時間邊界
時間邊界的核心測試方式是列出切換點前後的案例。狀態通常在某個 duration 前後改變,table-driven test 能讓這些情境集中呈現。
1func TestStatus(t *testing.T) {
2 startedAt := time.Date(2026, 4, 22, 10, 0, 0, 0, time.UTC)
3
4 tests := []struct {
5 name string
6 now time.Time
7 job Job
8 want string
9 }{
10 {
11 name: "active before idle threshold",
12 now: startedAt.Add(4 * time.Minute),
13 job: Job{StartedAt: startedAt},
14 want: "active",
15 },
16 {
17 name: "idle after threshold",
18 now: startedAt.Add(6 * time.Minute),
19 job: Job{StartedAt: startedAt},
20 want: "idle",
21 },
22 {
23 name: "completed ignores idle threshold",
24 now: startedAt.Add(30 * time.Minute),
25 job: Job{
26 StartedAt: startedAt,
27 FinishedAt: ptrTime(startedAt.Add(2 * time.Minute)),
28 },
29 want: "completed",
30 },
31 }
32
33 for _, tt := range tests {
34 t.Run(tt.name, func(t *testing.T) {
35 got := Status(tt.now, tt.job)
36 if got != tt.want {
37 t.Fatalf("Status() = %q, want %q", got, tt.want)
38 }
39 })
40 }
41}這個測試不需要 time.Sleep。案例名稱直接描述時間邊界,失敗時能快速定位是哪個規則壞了。
【策略】長生命週期元件用 time provider
Time provider 的核心用途是讓元件在多個方法中取得時間,但測試仍能控制時間來源。最輕量的形式是 func() time.Time。
1type Monitor struct {
2 now func() time.Time
3}
4
5func NewMonitor(now func() time.Time) Monitor {
6 if now == nil {
7 now = time.Now
8 }
9 return Monitor{now: now}
10}
11
12func (m Monitor) Snapshot(job Job) string {
13 return Status(m.now(), job)
14}測試提供固定時間:
1func TestMonitorSnapshot(t *testing.T) {
2 fixedNow := time.Date(2026, 4, 22, 10, 10, 0, 0, time.UTC)
3 monitor := NewMonitor(func() time.Time {
4 return fixedNow
5 })
6
7 got := monitor.Snapshot(Job{
8 StartedAt: fixedNow.Add(-10 * time.Minute),
9 })
10 if got != "idle" {
11 t.Fatalf("snapshot = %q, want idle", got)
12 }
13}這比導入大型 clock framework 更輕量,也比在測試裡等待真實時間更可靠。若整個專案有大量時間需求,再考慮統一 clock interface。
【判讀】Ticker 測試要拆排程與工作
Ticker 的核心問題是它同時包含「何時觸發」與「觸發時做什麼」。測試時應把單次工作抽出來,避免為了測狀態規則而等待 ticker。
1type Worker struct {
2 syncOnce func(context.Context) error
3}
4
5func (w Worker) Run(ctx context.Context, interval time.Duration) error {
6 ticker := time.NewTicker(interval)
7 defer ticker.Stop()
8
9 for {
10 select {
11 case <-ctx.Done():
12 return ctx.Err()
13 case <-ticker.C:
14 if err := w.SyncOnce(ctx); err != nil {
15 return err
16 }
17 }
18 }
19}
20
21func (w Worker) SyncOnce(ctx context.Context) error {
22 return w.syncOnce(ctx)
23}SyncOnce 可以單獨測規則,Run 只需要少數測試確認 context 取消與 ticker 排程。不要讓每個狀態測試都真的啟動 ticker。
【測試】Run 測試應用 context 控制退出
長生命週期 worker 的測試核心是讓退出條件可控。若只想測 context 取消,先取消 context 再呼叫 Run。
1func TestRunStopsWhenContextCanceled(t *testing.T) {
2 ctx, cancel := context.WithCancel(context.Background())
3 cancel()
4
5 worker := Worker{
6 syncOnce: func(context.Context) error {
7 t.Fatalf("syncOnce should not be called")
8 return nil
9 },
10 }
11
12 err := worker.Run(ctx, time.Hour)
13 if !errors.Is(err, context.Canceled) {
14 t.Fatalf("Run() error = %v, want context canceled", err)
15 }
16}這個測試不需要等待一小時。time.Hour 只是確保 ticker 不會在測試中自然觸發,真正的退出由 context 控制。
【判讀】sleep-based test 應該是例外
Sleep-based test 的核心問題是慢、不穩定、難以定位。排程、CI 負載與機器速度都可能讓測試偶發失敗。
反模式:
1func TestStatusWithSleep(t *testing.T) {
2 start := time.Now()
3 time.Sleep(6 * time.Minute)
4 got := Status(time.Now(), Job{StartedAt: start})
5 _ = got
6}這種測試不應存在。它拖慢測試套件,仍然不能保證結果穩定。正確做法是直接建構 now 與 StartedAt。
若真的要等待非同步事件,應使用 deadline 與條件重試,而不是固定 sleep;下一章的 integration test 會使用這個原則。
本章不處理
本章先處理時間作為輸入的可測性;更完整的 fake clock 與平台 timeout 合約,會在下列章節再往外延伸:
和 Go 教材的關係
這一章承接的是 defer、select loop 與 timeout 邊界;如果你要先回看語言教材,可以讀:
- Go:defer 與資源清理
- Go:select:同時等待多種事件
- Go:如何新增背景工作流程
- Go:graceful shutdown 與 signal handling
- Go 進階:graceful shutdown 與 signal handling
- Go 進階:Kubernetes、systemd 與 load balancer 合約
小結
時間控制測試的重點是把時間變成可指定輸入。純邏輯用 now time.Time,長生命週期元件用 func() time.Time,ticker 排程和單次工作分開測。避免 time.Sleep,測試才會快速、穩定且可重現。