🕕時間看似單純,但很難測試,靠 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…

在 Xcode 的測試上面按右鍵,選 Test Repeatedly…
在 Xcode 的測試上面按右鍵,選 Test Repeatedly…

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

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

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

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

很顯然的,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)
}

結果,你猜怎麼樣?會有一定的錯誤率。

錯誤結果顯示,有 93% 的情況 modifiedDate 並沒有 > createdDate。
錯誤結果顯示,有 93% 的情況 modifiedDate 並沒有 > createdDate

因為 init()Date()didSetDate() 是分成兩次呼叫,但在測試時有可能在系統時間的最小單位還沒切換之前,就連續被呼叫,導致無法達成兩個時間之間 > 的條件。

這又是一個 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 內建的功能。然後在測試的時候就可以覆蓋掉了。

語法如下: