雲端 Mac 上的 iOS 偏好設定回歸測試
同一套 iOS 設定測試,第一次通過,第二次卻讀到上次留下的佈景主題值;換到雲端 Mac 後,結果又不一樣。這通常不是機器效能的問題,而是測試混淆了程序內的預設值、已儲存的設定與啟動參數。在 VpsKeep 上執行這類任務時,應先隔離偏好設定,再檢查應用邏輯的行為,不要靠清空整個模擬器碰運氣。
先明確定義讀取契約
UserDefaults 並非只有一張鍵值表。讀取時,它會搜尋多個網域:啟動參數可以覆寫應用程式網域,註冊網域則提供備援值。因此,object(forKey:) 讀到某個值,不代表該值已經儲存。
先為每項設定明確定義三件事:鍵名、合法型別,以及缺少值時的結果。例如,佈景主題鍵只接受 system、light、dark;鍵不存在、型別不符或字串不在允許範圍內時,一律回傳 system。如此一來,舊版本留下的無效值也會有明確的處理結果。
register(defaults:) 只會註冊目前程序的備援值,不會將它們寫入使用者設定。刪除持久化值後,再次讀到預設值是正常的回退行為,並不代表刪除失敗。
不要用
dictionaryRepresentation()證明某個鍵已經儲存。它會彙整可見的設定,其中可能包含註冊網域的值;要確認儲存狀態,應讀取指定的持久化網域。
注入獨立 suite,禁止存取真實設定
正式環境的程式碼可以預設使用 .standard,但測試必須注入專用實例。不要讓設定封裝在方法內重新取得 .standard,否則依賴注入只是在表面上做了隔離。
以下程式碼可以放入 iOS 單元測試目標。每個測試都使用隨機 suite 名稱,而且只刪除自己建立的持久化網域。範例也將讀取封裝放在測試檔案中;整合至專案時,請將封裝移到應用程式目標,再由測試匯入。
import Foundation
import XCTest
struct ThemePreferences {
let defaults: UserDefaults
init(defaults: UserDefaults = .standard) {
self.defaults = defaults
defaults.register(defaults: ["theme": "system"])
}
var theme: String {
guard let value = defaults.object(forKey: "theme") as? String,
["system", "light", "dark"].contains(value) else {
return "system"
}
return value
}
}
final class ThemePreferencesTests: XCTestCase {
private func withSuite(
_ body: (UserDefaults, String) throws -> Void
) throws {
let name = "preferences-tests." + UUID().uuidString
let defaults = try XCTUnwrap(UserDefaults(suiteName: name))
defaults.removePersistentDomain(forName: name)
defer {
defaults.removePersistentDomain(forName: name)
}
try body(defaults, name)
}
func testOverrideAndRemoval() throws {
try withSuite { defaults, name in
let preferences = ThemePreferences(defaults: defaults)
XCTAssertEqual(preferences.theme, "system")
XCTAssertNil(
defaults.persistentDomain(forName: name)?["theme"]
)
defaults.set("dark", forKey: "theme")
XCTAssertEqual(preferences.theme, "dark")
XCTAssertEqual(
defaults.persistentDomain(forName: name)?["theme"]
as? String,
"dark"
)
defaults.removeObject(forKey: "theme")
XCTAssertNil(
defaults.persistentDomain(forName: name)?["theme"]
)
XCTAssertEqual(preferences.theme, "system")
}
}
func testInvalidValuesFallBack() throws {
try withSuite { defaults, _ in
let preferences = ThemePreferences(defaults: defaults)
defaults.set(["dark"], forKey: "theme")
XCTAssertEqual(preferences.theme, "system")
defaults.set("unknown", forKey: "theme")
XCTAssertEqual(preferences.theme, "system")
}
}
}
隨機名稱可避免平行測試互相覆寫。結束時的清理涵蓋正常執行路徑;若程序遭到強制終止,則不保證會執行 defer。殘留的隨機網域不會在下一輪被重複使用,但長期運作的測試裝置仍應定期盤點,且只清理確認屬於測試的網域。
補齊覆寫與刪除的邊界測試
最基本的檢查項目包括:沒有已儲存的值時回退、寫入合法值後覆寫、刪除後回退、型別錯誤時回退,以及未知列舉值的回退。還應加入「重新建立應用邏輯封裝後,仍能讀到已儲存的值」,避免封裝在每次初始化時都用 set 重設使用者的選擇。
如果應用邏輯同時使用共用 suite 與應用程式網域,不要在單元測試中隨意呼叫 addSuite(named:)。它會改變搜尋路徑,讓測試結果受到其他網域影響。應先將共用設定視為另一個可注入的依賴,分別驗證,再進行應用程式整合測試。
布林設定也必須區分「尚未設定」與「使用者選擇關閉」。只呼叫 bool(forKey:),會將缺少的值讀成 false,因此無法判斷使用者是否曾經操作;必須明確檢查鍵是否存在,或另外設置帶有版本資訊的狀態欄位。
用兩個程序驗證真正的持久化
在同一個程序內建立新的 UserDefaults 物件,只能證明讀取行為,不能證明重新啟動後仍能保留設定。整合測試應分成寫入與讀取兩個階段,並使用同一個固定的測試 suite。
第一階段寫入佈景主題,透過畫面或測試介面確認能讀回該值,再讓應用程式正常結束。第二階段啟動應用程式,不再寫入,直接檢查佈景主題是否恢復。最後執行獨立的清理階段,刪除這個測試網域。
不要將強制終止程序後立即讀取的結果,當成一般儲存功能的驗收依據;那是在檢查異常結束的邊界行為。也不要靠固定等待幾秒或呼叫 synchronize(),就宣稱資料已完成寫入磁碟。
啟動參數是另一條測試線。如果應用程式透過參數注入佈景主題,應單獨驗證其覆寫行為,並在後續啟動時移除參數;否則,參數會遮蔽已儲存的值,讓持久化測試出現假成功。
將失敗結果整理成可追查的紀錄
雲端 Mac 的測試管線只需記錄測試名稱、suite 名稱、應用程式版本、鍵是否存在、實際型別與預期回退結果。不要直接匯出整份設定字典,因為其中可能包含使用者識別資訊或內部環境資訊。
先連續執行兩輪,再啟用平行執行,檢查結果是否一致。如果只有第二輪失敗,先找固定的 suite 與未清理的網域;如果只有平行執行時失敗,檢查共用的單例;如果只有重新啟動後失敗,檢查初始化是否覆寫了使用者的值。
最終驗收標準不是「某次讀到了正確字串」,而是預設值、使用者覆寫、刪除後回退與程序重新啟動,都有各自獨立的證據。明確定義這些邊界後,設定測試才能在新機器與長期運作的節點上得到一致的結論。
常見問題
register(defaults:) 會將預設值寫入持久化檔案嗎?
不會。它將值放入目前程序的註冊網域,作為查詢時的後備值。新程序仍須執行註冊,不能視為已儲存的使用者設定。
重新建立 UserDefaults 物件能證明資料已寫入磁碟嗎?
不能。同一程序內的物件可能共用設定狀態。應讓寫入程序正常結束,再以另一個程序讀取;不要用 synchronize() 取代這項驗收。
在專屬雲端 Mac 上執行工作流程
租用整台實體 Mac mini,非虛擬機器。可選擇日租、週租、月租或季租;下單前請先確認機型與節點,實際供應狀況以控制台即時顯示為準。