時間控制測試的核心原則是把「現在」變成可指定輸入。只要程式邏輯依賴目前時間、deadlinetimeout、ticker 或過期判斷,測試就不應依賴真實等待。

本章目標

學完本章後,你將能夠:

  1. now time.Time 測試純狀態轉移
  2. func() time.Time 注入長生命週期元件的時間來源
  3. 用 table-driven test 覆蓋時間邊界
  4. 把 ticker 排程與單次工作拆開測
  5. 避免 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}

這種測試不應存在。它拖慢測試套件,仍然不能保證結果穩定。正確做法是直接建構 nowStartedAt

若真的要等待非同步事件,應使用 deadline 與條件重試,而不是固定 sleep;下一章的 integration test 會使用這個原則。

本章不處理

本章先處理時間作為輸入的可測性;更完整的 fake clock 與平台 timeout 合約,會在下列章節再往外延伸:

和 Go 教材的關係

這一章承接的是 defer、select loop 與 timeout 邊界;如果你要先回看語言教材,可以讀:

小結

時間控制測試的重點是把時間變成可指定輸入。純邏輯用 now time.Time,長生命週期元件用 func() time.Time,ticker 排程和單次工作分開測。避免 time.Sleep,測試才會快速、穩定且可重現。