使用者升級 iOS App 後,裝置上可能還留著舊版寫入的 JSON 快取。此時伺服器回傳的最新回應能正常解碼,不代表舊快取也讀得出來。常見的問題是替 Codable 模型新增欄位後,測試只涵蓋新回應,直到使用者開啟離線頁面才遇到解碼錯誤。解法不是讓所有錯誤都悄悄通過,而是把真實的歷史資料固定成樣本,明確區分哪些變更應該相容、哪些必須報錯。
先界定相容範圍
以帳戶摘要為例:id 和 status 是業務必需欄位,nickname 是後來新增的選填欄位。預期結果是:舊資料沒有 nickname 也能讀取;新資料多了用戶端尚未認識的鍵也能讀取;缺少 id 則必須失敗。這三種輸入應分開測試,不能只靠一份「正常回應」證明相容性。
在合成的 Decodable 實作中,String? 可以接受缺少的值,結果為 nil;非選填的 String 若缺少則會擲出錯誤。合成實作通常也會忽略未知鍵。但這些行為只涵蓋解碼這一層:如果畫面接著強制解包 nickname,舊快取仍會造成故障。模型測試通過後,還應檢查使用該欄位的程式碼是否提供備用顯示內容。
把「讀得懂舊資料」和「拒絕損壞資料」都寫進測試。只測前者,很容易把真正的資料錯誤誤當成相容性處理。
將歷史回應保存為固定樣本
在測試目標中建立 Fixtures 目錄,存放 account-v1.json、account-v2.json 和 account-missing-id.json。檔名應描述資料形態,而不是某次 CI 的執行日期。將三個檔案加入測試目標的 Copy Bundle Resources;只在專案目錄建立檔案,測試執行時不會自動找得到。
account-v1.json 保留舊回應:有 id、status,沒有 nickname。account-v2.json 加入 nickname,以及一個目前模型未宣告的鍵。第三份樣本移除 id,作為預期失敗的反例。樣本應根據已去識別化的資料形態整理:替換個人資料與權杖,但保留欄位型別、欄位缺失情況和巢狀層級。不要每次介面變動就覆蓋舊檔,否則測試將失去發現迴歸問題的能力。
如果伺服器把欄位從字串改為數字,應另存新版本,並先確認 App 是否必須同時支援兩種格式;不要只為了讓測試通過,就替所有欄位加上寬鬆的字串轉換。確實需要相容時,應編寫明確的自訂解碼分支和對應樣本。
用 XCTest 驗證三種結果
以下測試假設樣本已複製到測試 bundle 的根目錄。範例中的 AccountSnapshot 是獨立宣告的;實際專案應改用待測的正式模型,避免測試通過的只是另一份臨時結構。
import Foundation
import XCTest
private struct AccountSnapshot: Decodable {
let id: String
let status: String
let nickname: String?
}
final class CodableContractTests: XCTestCase {
private func decode(_ name: String) throws -> AccountSnapshot {
let url = try XCTUnwrap(
Bundle(for: Self.self).url(
forResource: name,
withExtension: "json"
)
)
let data = try Data(contentsOf: url)
return try JSONDecoder().decode(AccountSnapshot.self, from: data)
}
func testOldResponseRemainsReadable() throws {
let account = try decode("account-v1")
XCTAssertEqual(account.id, "example-001")
XCTAssertNil(account.nickname)
}
func testUnknownFieldDoesNotBreakDecoding() throws {
let account = try decode("account-v2")
XCTAssertEqual(account.id, "example-001")
XCTAssertEqual(account.nickname, "Tester")
}
func testMissingRequiredFieldFails() {
XCTAssertThrowsError(try decode("account-missing-id"))
}
}
讓兩份可讀樣本使用相同的範例 id,測試斷言才有明確意義。目前的失敗測試只要求擲出錯誤;如果錯誤型別本身也是資料處理契約的一部分,就進一步斷言 DecodingError.keyNotFound 及其欄位名稱。如此才能區分「如預期拒絕缺少必要欄位的資料」與「根本找不到測試資源」。
日期與列舉不要靠預設行為猜測
如果樣本含有日期,先核對正式程式碼實際使用的 JSONDecoder.dateDecodingStrategy;測試必須採用相同設定。如果狀態值從固定字串擴充出新的列舉成員,也應決定要保留未知狀態並顯示通用文字,還是拒絕處理。將決策寫進模型和樣本,不要由測試臨時另訂一套解碼策略。
加入雲端 Mac 的測試工作
先用 xcodebuild -list 核對專案與已共享的 scheme,再從目前可用的模擬器清單取得裝置 ID。以下指令使用的環境變數由 CI 工作提供;TEST_TARGET 填入測試目標名稱。使用裝置 ID 可避免同名模擬器造成目的地不明確:
: "${PROJECT:?Set PROJECT}"
: "${SCHEME:?Set SCHEME}"
: "${SIM_ID:?Set SIM_ID}"
: "${TEST_TARGET:?Set TEST_TARGET}"
xcodebuild \
-project "$PROJECT" \
-scheme "$SCHEME" \
-destination "platform=iOS Simulator,id=$SIM_ID" \
"-only-testing:$TEST_TARGET/CodableContractTests" \
test
在 CDNMac 雲端 Mac 上,先確認所選的 Xcode 能開啟專案、scheme 已共享,且測試資源已納入 bundle,再執行工作。這裡執行的是資料契約測試,不需要簽署資料,也不需要連接實體裝置。CI 應保留 xcodebuild 的結束狀態:樣本遺失、解碼失敗或斷言不符,都應讓工作失敗,不能靠日誌關鍵字判斷是否成功。
維護樣本時要檢查什麼
模型變更進入程式碼審查時,逐項確認:舊快取是否仍會被讀取?新增欄位在舊資料中缺少時該如何顯示?未知鍵是否應被忽略?必要欄位損壞時,錯誤會如何處理?接著確認測試使用的是正式模型與正式解碼設定,而不只是執行一份看起來相同的範例實作。
測試失敗時,先找出錯誤來源。找不到檔案,就檢查目標成員資格與 bundle;遇到 keyNotFound,就確認該欄位究竟是必要資料,還是新增的選填欄位;遇到 typeMismatch,就檢查真實資料型別是否改變;斷言失敗,則檢查解碼後的業務含義。不要把這些情況一律改成 try? 並回傳空物件。保留下來的歷史樣本,就是團隊對「哪些資料仍可讀取」所建立的可執行紀錄。
常見問題
新增選填欄位後,還需要保留舊 JSON 樣本嗎?
需要。舊快取可能沒有新欄位;保留樣本才能持續確認缺少欄位時會解碼為 nil,而非讓整個物件失敗。
未知欄位應讓 Codable 解碼失敗嗎?
通常不需要。自動合成的 Decodable 會忽略未宣告的 JSON 鍵;必要欄位缺少的失敗情況則應另外測試。
讓下一次 iOS 建置在專屬雲端 Mac 上執行
採用 M4、16GB RAM 與 256GB SSD 的固定實體環境,可從六個節點與四種租期中,選擇最適合目前任務的組合。