同一張資料卡在切換帳號後仍顯示舊內容,不一定是網路快取造成的:記憶體中的物件可能只以資源編號作為索引,沒有納入帳號身分。在 ArmShell 雲端 Mac 上執行這類回歸測試時,不必刻意製造記憶體壓力。先針對快取鍵的身分、寫入時的快照、清除介面與未命中行為撰寫測試,再另外觀察系統汰除行為。
先明確定義快取契約
本文只測試處理程序內的 NSCache,不涵蓋磁碟快取與網路回應快取。請準備一個啟用 XCTest 的 iOS 單元測試目標,將下列型別與測試放在同一個測試檔案中。每次測試處理程序啟動時都重新建立快取,不沿用全域單例。
約定快取鍵由帳號、資源編號與內容版本組成。不同帳號下的相同編號不能互相命中;版本變更後,也不能把舊資料當成新版本的結果。快取值以 Data 儲存,寫入後即使呼叫端繼續修改自己的變數,也不應改變快取中的快照。
快取是可隨時遺失的加速層,不是權威資料來源。每次讀取都必須允許未命中,業務邏輯不能仰賴快取物件持續存在才能正確運作。
這裡不測試密碼、權杖或完整的使用者資料。測試資料只使用短文字,失敗日誌也只記錄測試案例名稱與命中狀態。
以完整身分建立快取鍵
不要直接用連字號串接三個欄位:如果欄位本身包含分隔符號,就可能產生歧義。以下透過物件的相等性表達快取鍵的身分;雜湊值可以碰撞,但相等性判斷必須逐一比較欄位。NSCache 不會替你複製鍵,因此鍵的欄位必須維持不可變。
import Foundation
import XCTest
final class MemoryKey: NSObject {
let account: String
let resource: String
let revision: String
init(_ account: String, _ resource: String, _ revision: String) {
self.account = account
self.resource = resource
self.revision = revision
}
override var hash: Int {
var hasher = Hasher()
hasher.combine(account)
hasher.combine(resource)
hasher.combine(revision)
return hasher.finalize()
}
override func isEqual(_ object: Any?) -> Bool {
guard let other = object as? MemoryKey else { return false }
return account == other.account
&& resource == other.resource
&& revision == other.revision
}
}
@MainActor
final class SessionMemoryCache {
private let storage = NSCache<MemoryKey, NSData>()
init() {
storage.totalCostLimit = 4 * 1024 * 1024
}
func store(_ data: Data, for key: MemoryKey) {
storage.setObject(
NSData(data: data),
forKey: key,
cost: data.count
)
}
func read(_ key: MemoryKey) -> Data? {
guard let value = storage.object(forKey: key) else { return nil }
return Data(referencing: value)
}
func clear() {
storage.removeAllObjects()
}
}
範例將包裝層限制在主執行器上,方便與介面狀態保持一致;這不代表 NSCache 本身只能從主執行緒存取。若需要從背景存取,應另外定義隔離方式,而不是直接移除限制。
測試隔離、版本與快照
第一組測試刻意讓不同帳號使用相同的資源編號,並以新建的等價鍵讀取,避免測試只證明「同一個鍵物件可以讀回資料」。第二組則修改原始 Data,再檢查快取值與明確呼叫清除後的結果。
final class SessionMemoryCacheTests: XCTestCase {
@MainActor
func testIdentityIsolation() {
let cache = SessionMemoryCache()
cache.store(Data([1]), for: MemoryKey("a", "card", "v1"))
cache.store(Data([2]), for: MemoryKey("b", "card", "v1"))
XCTAssertEqual(
cache.read(MemoryKey("a", "card", "v1")), Data([1])
)
XCTAssertEqual(
cache.read(MemoryKey("b", "card", "v1")), Data([2])
)
XCTAssertNil(cache.read(MemoryKey("a", "card", "v2")))
}
@MainActor
func testSnapshotAndClear() {
let cache = SessionMemoryCache()
let key = MemoryKey("a", "card", "v1")
var source = Data([1, 2])
cache.store(source, for: key)
source.append(3)
XCTAssertEqual(cache.read(key), Data([1, 2]))
cache.clear()
XCTAssertNil(cache.read(key))
}
}
這些小型物件用於資源充足時的功能回歸測試。如果系統恰好汰除物件,命中斷言仍可能失敗。業務層需要嚴格確定性的測試時,應注入以字典實作的快取替身;不要讓整套測試仰賴系統持續保留物件。
也應加入同鍵覆寫的案例:連續寫入兩個值,讀取結果應是第二次寫入的資料。空 Data 與未命中也必須區分,前者是有效值,後者則是 nil。
別把容量提示當成汰除演算法
範例以位元組作為成本單位,因為每次寫入時都傳入 data.count。totalCostLimit 的單位由呼叫端約定,並不是自動統計的處理程序記憶體用量;物件、鍵與容器本身仍有額外負擔。如果儲存的是解碼後的圖片,也不能用壓縮檔案的長度代表實際成本。
countLimit 與 totalCostLimit 都不是嚴格上限。不能在插入若干物件後,就斷言最早寫入的物件一定會消失,也不能假設 NSCache 實作了確定性的最近最少使用策略。壓力實驗只回報命中率與處理程序記憶體用量的變化,不應將其當成每次合併程式碼時都必須通過的固定結果。
同樣地,不要藉由無限配置大型陣列來觸發系統清理:這很容易先讓測試處理程序遭到終止,得到的只是資源耗盡的結果,而不是快取契約的驗證依據。
將未命中與登出流程納入驗收
在業務讀取層注入一個始終回傳 nil 的快取替身,驗證載入器仍能取得權威資料。接著注入已有值的替身,驗證允許使用快取的分支不會重複載入。如此便能直接檢查兩條路徑,不必等待實際發生汰除。
登出帳號時,先停止舊帳號的請求並更新工作階段身分,再清空快取。只清空並不足夠:舊請求若隨後完成,仍可能將資料寫回。寫入前應比較請求擷取的工作階段世代與目前世代,兩者不一致就放棄寫入;這項規則屬於載入協調層,應獨立測試。
最後檢查四項:帳號鍵是否來自可信的工作階段、版本欄位是否隨內容更新、登出後舊請求是否還能寫回,以及快取未命中時是否仍能完成載入。將穩定的業務斷言納入日常回歸測試,把不穩定的壓力觀察留作診斷用途,才不會讓快取成為偶發故障背後隱藏的依賴條件。
常見問題
設定 countLimit 後,可以斷言快取只剩指定數量的物件嗎?
不可以。countLimit 和 totalCostLimit 都不是能用於精確容量斷言的硬性限制。應測試未命中後的復原路徑;若需要嚴格容量與汰除順序,應另外實作確定性的快取策略。
如何驗證登出後不會讀取上一個帳戶的快取?
快取鍵必須包含帳戶命名空間,登出時呼叫明確清除入口。測試應以同一資源識別碼分別寫入兩個帳戶,再驗證讀取隔離與清除後的未命中。