遠端建置一旦依賴真實測試 API,就很容易發生同一個提交上午通過、下午卻失敗的情況:測試資料遭到改寫、權杖過期、閘道器觸發限流,或 API 只是暫時變慢。更棘手的是,失敗現場通常只留下一個解碼錯誤,無法確認用戶端實際收到了什麼。解決方法不是增加重試次數,而是將網路層拆成兩類驗證:大多數回歸測試使用本機固定回應,只有少量契約測試存取真實環境。
先劃清離線測試的邊界
URLProtocol 位於 URLSession 的請求鏈路中,可以在請求真正送出前傳回自訂回應。它適合驗證四件事:請求方法與路徑是否正確、請求標頭與本文是否完整、模型能否解碼,以及業務層能否將狀態碼對應為明確錯誤。
它無法證明真實服務目前可供存取,也無法發現閘道器設定變更。因此,建議將測試分層:
| 層級 | 資料來源 | 主要檢查 | 執行頻率 |
|---|---|---|---|
| 單元回歸 | 本機 JSON 樣本 | 解碼與錯誤對應 | 每次提交 |
| 網路層回歸 | URLProtocol | 請求與回應閉環 | 每次提交 |
| 契約測試 | 受控測試 API | 欄位與驗證契約 | 定期或發佈前 |
離線回歸的目標是消除無關波動,而不是偽造一個永遠成功的請求。404、429、500、空白回應與損毀的 JSON 都應成為固定測試案例。
注入獨立的 URLSession
不要在測試中全域註冊協定,也不要讓業務程式碼直接存取 URLSession.shared。為 APIClient 注入一個工作階段:正式環境使用預設設定,測試環境則使用 ephemeral 設定。如此便不會繼承磁碟快取、Cookie 或舊連線狀態。
final class StubURLProtocol: URLProtocol {
static var handler: ((URLRequest) throws -> (HTTPURLResponse, Data))?
override class func canInit(with request: URLRequest) -> Bool {
request.url?.host == "api.test.invalid"
}
override class func canonicalRequest(for request: URLRequest) -> URLRequest {
request
}
override func startLoading() {
guard let handler = Self.handler else {
client?.urlProtocol(self, didFailWithError: URLError(.resourceUnavailable))
return
}
do {
let (response, data) = try handler(request)
client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
client?.urlProtocol(self, didLoad: data)
client?.urlProtocolDidFinishLoading(self)
} catch {
client?.urlProtocol(self, didFailWithError: error)
}
}
override func stopLoading() {}
}
func makeTestSession() -> URLSession {
let configuration = URLSessionConfiguration.ephemeral
configuration.protocolClasses = [StubURLProtocol.self]
configuration.timeoutIntervalForRequest = 3
return URLSession(configuration: configuration)
}
canInit 必須限定主機或自訂請求標頭。若無條件傳回 true,測試程序中的其他 URLSession 也可能遭到攔截,讓錯誤更難追查。
將回應樣本當成程式碼維護
建議將樣本放在測試 Target 的 Fixtures/API/v1/ 下,並依資源與情境命名,例如 projects-success.json、projects-empty.json 與 projects-malformed.json。不要直接儲存含有動態權杖、電子郵件地址或內部位址的完整封包擷取內容。
提交前,可以統一排序鍵值並驗證語法:
find Tests/Fixtures -name '*.json' -print0 |
while IFS= read -r -d '' file; do
tmp="${file}.tmp"
jq -S . "$file" > "$tmp" && mv "$tmp" "$file"
done
即使是成功案例,也要對請求本身進行斷言。若只檢查最終模型,可能會掩蓋用戶端將 GET 誤寫成 POST、遺漏版本標頭,或重複編碼查詢參數等問題。
StubURLProtocol.handler = { request in
XCTAssertEqual(request.httpMethod, "GET")
XCTAssertEqual(request.url?.path, "/v1/projects")
XCTAssertEqual(request.value(forHTTPHeaderField: "Accept"), "application/json")
let data = try Data(contentsOf: fixtureURL("projects-success"))
let response = HTTPURLResponse(
url: try XCTUnwrap(request.url),
statusCode: 200,
httpVersion: "HTTP/1.1",
headerFields: ["Content-Type": "application/json"]
)!
return (response, data)
}
逐類驗收錯誤分支
至少要涵蓋傳輸失敗、HTTP 失敗與內容失敗。傳輸失敗可拋出 URLError(.timedOut);HTTP 失敗應傳回真實狀態碼與結構化錯誤本文;內容失敗則傳回 200,但提供缺少欄位、型別錯誤或遭截斷的 JSON。這三類錯誤不應在業務層全都變成同一個「未知錯誤」。
測試逾時時,不要真的等待數十秒。讓 handler 直接拋出對應錯誤,再驗證介面模型是否進入可重試狀態。針對 429,還應檢查用戶端是否讀取 Retry-After,但測試中不要真的暫停等待;請將退避計算抽成純函式,另外驗證其輸入與結果。
每個測試結束後,都要將 handler 設為 nil。若測試套件啟用平行執行,單一靜態 handler 可能被其他測試案例覆寫。最穩妥的起點是讓這個套件循序執行;若確實需要平行執行,請使用請求 ID 到回應閉包的對應表,並以鎖定機制或 actor 保護登錄表。
在雲端 Mac 上穩定執行
在 DplyMini 的雲端 Mac 上,先固定 Xcode 選擇,再使用同一個 workspace、scheme 與測試計畫執行。獨享實體機並非虛擬機,但測試仍應明確隔離 DerivedData,避免不同分支共用中間產物。
set -euo pipefail
sudo xcode-select -s /Applications/Xcode.app
rm -rf "$PWD/.derived-data"
xcodebuild test \
-workspace App.xcworkspace \
-scheme App \
-testPlan NetworkRegression \
-destination 'platform=iOS Simulator,name=iPhone 16' \
-derivedDataPath "$PWD/.derived-data" \
-resultBundlePath "$PWD/TestResults/NetworkRegression.xcresult"
首次執行前,請使用 xcodebuild -showdestinations 確認目前執行環境中已有的模擬器名稱,不要將個人電腦上的裝置名稱直接複製到指令碼。發生失敗時,應保留 xcresult、測試記錄與對應的樣本版本;不要只截取主控台最後幾行。
最終驗收應符合四項條件:中斷外部網路後,離線套件仍能完整執行;連續執行的結果一致;樣本變更能在程式碼審查中清楚辨識;真實契約測試失敗時,不會妨礙開發者判斷用戶端邏輯是否發生回歸。做到這四點,網路測試才算真正從「依賴 API 運氣」轉變為可維護的工程資產。
常見問題
URLProtocol 測試可以完全取代真實 API 整合測試嗎?
不可以。它適合穩定驗證請求組裝、回應解碼與錯誤映射;發佈前仍應保留少量真實環境契約測試,以發現驗證、閘道與伺服器欄位變更。
為什麼測試要注入獨立建立的 URLSession?
獨立的 ephemeral 設定能限制攔截範圍,避免共用快取、Cookie 與全域協定註冊影響其他測試。
並行測試如何避免共用 handler 被互相覆寫?
簡單實作應讓測試套件循序執行。需要並行時,應依請求識別碼保存回應,並以鎖或 actor 保護註冊表。
依任務長度選擇雲端 Mac 租用週期
兩種 M4 設定可按日、週、月或季租用;節點與實際可用資訊以控制台即時回傳內容為準。