🕕時間看似單純,但很難測試,靠 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 內建的功能。然後在測試的時候就可以覆蓋掉了。
語法如下:
繼續閱讀
解鎖全文
訂閱⟪13報+⟫,閱讀本篇全文,並解鎖 200 篇以上專欄與短篇。
- 完整專欄文章與深度分享
- Apple 開發者週報完整版
- 會員限定短篇抒發
- 支持 13 寫作
延伸閱讀
❎iOS 本地 AI:用 AnyLanguageModel 加 MLX 跑開源模型
AnyLanguageModel 讓 Hugging Face 上的開源 LLM 很容易下載與開始使用,只要三行程式碼就可以呼叫 MLX 框架以及配套工具。我也更新了範例專案,可以直接在 Mac 與 iOS 裝置上試玩。
✨Swift Dependencies 的語法靈感來自 SwiftUI Environment
Swift Dependencies 的語法設計受到 SwiftUI Environment 啟發,兩者的宣告方式幾乎完全相同。透過互相對照,讓你一次學會兩種機制。
📦解決 SwiftSyntax 編譯超慢的問題
我過去對於推廣使用 Point-Free 的許多套件有所保留,因為使用 Swift Macros 來簡化語法,連帶依賴的 SwiftSyntax 會浪費大量的編譯時間。我甚至為了這個問題而換電腦。幸好自從 Xcode 16.4 以後獲得解決了!