in
要測試什麼規則?
假設通知發送失敗後,必須等待 至少 5 分鐘,才可以重新發送。
我們希望驗證以下三種情境:
| 上次失敗時間 | 現在時間 | 經過多久 | 預期結果 |
|---|---|---|---|
| 10:00:00 | 10:04:59 | 4 分 59 秒 | 不可重試 |
| 10:00:00 | 10:05:00 | 剛好 5 分鐘 | 可以重試 |
| 10:00:00 | 10:05:01 | 5 分 1 秒 | 可以重試 |
要驗證這條規則,測試需要控制兩個時間:
- 上次失敗時間:可以直接建立測試資料指定。
- 現在時間:如果程式直接使用
DateTime.Now,就會讀取電腦當下的時間,無法由測試直接指定。
為什麼「現在時間」需要可以控制?
測試設定資料後,到實際呼叫方法進行判斷,中間仍然會經過時間。
1 | 測試設定資料 |
如果測試的是「已經過了 10 分鐘」,這點時間差通常不影響結果。
但如果測試的是「還差 1 毫秒才滿 5 分鐘」,判斷結果就可能因為執行速度而改變。
我們希望測試結果取決於程式邏輯,而不是測試當下電腦跑得多快。
TimeProvider 如何解決?
讓程式透過一個可以替換的「時鐘」取得時間:
| 執行情境 | 使用的時鐘 | 取得的時間 |
|---|---|---|
| 正式執行 | TimeProvider.System |
真實的現在時間 |
| 單元測試 | 測試建立的時鐘替身 | 測試指定的固定時間 |
這不會修改電腦的系統時間,只會影響使用該時鐘的程式。
接下來用同一條重試規則,比較兩種寫法。範例是簡化的重試判斷,方便理解時間如何影響測試。
① 實際程式碼
程式直接讀取系統時間,判斷距離上次失敗是否已滿 5 分鐘。
1 | public class RetryChecker |
② 單元測試程式碼
這個測試想驗證:
還差 1 毫秒才滿 5 分鐘,應該不能重試。
1 | using System; |
③ 模擬執行過程
假設測試設定資料的瞬間,真實時間是 10:00:00.000:
1 | 設定上次失敗時間: |
接著,測試呼叫 CanRetry()。假設方法執行到讀取時間時,已經過了 2 毫秒:
| 步驟 | 真實時間 | 發生什麼事 |
|---|---|---|
| 測試設定資料 | 10:00:00.000 | 上次失敗時間設為 09:55:00.001 |
| 到達重試門檻 | 10:00:00.001 | 已經滿 5 分鐘 |
方法讀取 DateTime.Now |
10:00:00.002 | 判斷可以重試,回傳 true |
| 測試驗證 | — | 原本預期 false,測試失敗 |
時間順序如下:
1 | 10:00:00.000 10:00:00.001 10:00:00.002 |
為什麼這個測試不穩定?
程式判斷沒有錯,是測試原本安排的情境,在判斷前已經改變了。
測試想驗證「還差 1 毫秒」,但方法真正讀取時間時,可能已經超過門檻。
- 執行得夠快,可能得到
false,測試通過。 - 執行稍慢,可能得到
true,測試失敗。 - 中間停下來除錯,也會讓真實時間繼續前進。
因此,直接使用 DateTime.Now 並不是完全不能測試,而是難以穩定重現精準的時間邊界。
① 實際程式碼
改成透過注入的 TimeProvider 取得時間。
GetUtcNow() 回傳 UTC 時間,因此這個範例統一使用 DateTimeOffset 表示時間。
1 | public class RetryChecker |
判斷規則仍然相同:
1 | 現在時間 ≥ 上次失敗時間 + 5 分鐘 |
改變的是:「現在時間」改由傳入的時鐘提供。
② 單元測試程式碼
使用 NSubstitute 建立一個時鐘替身,固定回傳指定時間。
1 | using System; |
關鍵是這兩行:
1 | var clock = Substitute.For<TimeProvider>(); |
意思是:
建立一個測試時鐘。每次呼叫它的
GetUtcNow(),都回傳指定的now。
單純建立 TimeProvider 並不會自動固定時間;是測試中的 Returns(now) 設定了固定回傳值。
③ 模擬執行過程
以下時間均為 UTC:
| 步驟 | 程式讀到的時間 | 發生什麼事 |
|---|---|---|
| 測試設定時鐘 | 10:00:00.000 | 固定現在時間 |
| 測試設定失敗時間 | — | 上次失敗時間為 09:55:00.001 |
呼叫 CanRetry() |
— | 真實世界的時間仍然前進 |
方法呼叫 _clock.GetUtcNow() |
10:00:00.000 | 測試時鐘仍回傳固定時間 |
| 執行判斷 | — | 尚未到 10:00:00.001,回傳 false |
| 測試驗證 | — | 符合預期,測試通過 |
1 | 真實世界: |
即使中間停下來除錯,測試時鐘仍然回傳相同時間,因此能穩定重現「還差 1 毫秒」的情境。
④ 正式執行時怎麼辦?
正式環境使用系統時鐘:
1 | var checker = new RetryChecker(TimeProvider.System); |
如果透過 DI 建立物件,就註冊:
1 | services.TryAddSingleton<TimeProvider>(TimeProvider.System); |
這表示:如果還沒有註冊 TimeProvider,就提供系統時鐘。
因此,正式環境仍會取得真實時間;只有測試才換成指定時間的替身。
用同一個測試,驗證門檻前、門檻當下、門檻後
把「上次失敗時間」固定在 UTC 10:00:00,再分別指定不同的現在時間:
1 | using System; |
這三筆測試資料分別代表:
| 測試資料 | 假裝現在 | 預期 |
|---|---|---|
299, false |
10:04:59 | 不可重試 |
300, true |
10:05:00 | 可以重試 |
301, true |
10:05:01 | 可以重試 |
不需要真的等 5 分鐘,三種情境就能依序執行。
使用假的時間,還算是在測真正的程式嗎?
算,因為我們只控制「現在時間」,並沒有替程式決定判斷結果。
1 | 測試提供: |
如果實際程式不小心把 >= 改成 >:
1 | return _clock.GetUtcNow() > lastFailedTime.AddMinutes(5); |
「剛好滿 5 分鐘」那筆測試就會失敗,因為程式錯誤地回傳了 false。
這正是測試要幫我們發現的問題。


