🕕時間看似單純,但很難測試,靠 Swift Dependencies 來救
透過 Swift Dependencies 最基本的功能,就做到讓原本無法掌握的系統時間,變得完全可控。讀完肯定讓你躍躍欲試!
Swift Dependencies 的官方文件雖然很詳細,但是我想挑戰用自己的方式去說明。就從前一篇有提到的 Date
開始說起吧!
假設我們的 app 是個筆記軟體,有一個 Note
type。裡面有建立與修改筆記的時間欄位:
struct Note { // v1
let createdDate = Date()
var modifiedDate = Date()
var text: String = ""
}
這樣寫的話,兩個欄位的日期有低機率會不相同。
以我在 M4 的 Mac 上反覆執行以下測試 1,000 次,有 2% 的機率會有時間差:
import Testing
@Test("Init Note and ensure modified date is the same as created date")
func initNote() {
let sut = Note()
#expect(sut.createdDate == sut.modifiedDate)
}
提示:如何在 Xcode 進行反覆測試
1. 在 Xcode 的測試上面按右鍵,選 Test Repeatedly…

2. 選 Maximum,並輸入一個較大的數字,再按 Run

3. 錯誤結果顯示,有 2% 的情況 createdDate
與 modifiedDate
並不相等。

createdDate
與 modifiedDate
並不相等。很顯然的,Note
在 init 的時候,兩個 properties 分別呼叫了一次 Date()
,而兩次得到的系統時間,可能有極細微的差異。實際情況會依照硬體的運算速度而不同。
Note()
的這種現象,我們可以稱之為 non-deterministic,也就是無法得到完全穩定的結果。
要避免這種問題,我們要修改程式碼,讓 Date()
只使用到一次。:
struct Note {
let createdDate: Date
var modifiedDate: Date
init() {
let date = Date()
self.createdDate = date
self.modifiedDate = date
}
}
再跑重複 1,000 次的測試,就完全通過了。
現在,Date()
寫在 init()
裡面,所以沒有辦法從外部修改。也許我們可以把 date
丟進 init
當參數,再設個預設值:
init(date: Date = Date()) { ... }
這樣,一切問題就解決了嗎?
修改的時候更新日期
事情沒有這麼簡單。
比如說,我想確保 Note
的文字被修改時,modifiedDate
會更新,所以我們幫 text
增加一個 didSet
來修改 modifiedDate
。
struct Note {
let createdDate: Date
var modifiedDate: Date
var text: String = "" {
didSet {
modifiedDate = Date()
}
}
init(date: Date = Date()) {
self.createdDate = date
self.modifiedDate = date
}
}
測試就期望 modifiedDate
要比 createdDate
更晚。
@Test("When note text updated, modifiedDate also updated")
func updateNoteText() {
var sut = Note()
sut.text = "New"
#expect(sut.modifiedDate > sut.createdDate)
}
結果,你猜怎麼樣?會有一定的錯誤率。

modifiedDate
並沒有 > createdDate
。因為 init()
的 Date()
與 didSet
的 Date()
是分成兩次呼叫,但在測試時有可能在系統時間的最小單位還沒切換之前,就連續被呼叫,導致無法達成兩個時間之間 >
的條件。
這又是一個 non-deterministic 的情況,真頭痛。
我們先把這個問題暫時放一邊,再舉一個稍微複雜但實際的需求。
再稍微複雜一點的需求
比如說,這個筆記 app,如果遇到 Note
的最後修改時間超過一個月,在畫面上的顏色就要變淡。
這種需求屬於畫面上的邏輯,我們可以另外寫一個 computed var,放在 extension。例如:
extension Note {
var isOutdated: Bool {
// grayed out notes edited > ~one month
Date().timeIntervalSince(modifiedDate) >= 30 * 86_400
}
}
(請注意,30 * 86400 不是一個很精準的一個月的定義。比較好作法是用 Calendar
做日期的計算,不過這邊我們可以先簡化。)
如果要進行測試的話,問題就大了。我們要怎麼創造出一個修改日期超過一個月的 Note
?
上述的例子都還只是很簡單的 model 單元測試需求,但是遇到 Date()
這種由系統控制結果的東西,測試就變得幾乎無法撰寫,或是得不到決定性的結果。
顯然,我們不應該直接使用 Date()
,而是要掌控「讀取當前系統時間」時,拿到的值。
掌控時間
透過 Swift Dependencies,我們可以把前述的 Date()
都改寫成 @Dependency(\.date.now)
,這是 Swift Dependencies 內建的功能。然後在測試的時候就可以覆蓋掉了。
語法如下: