用 URLProtocol 建立可重現的 iOS API 離線回歸測試

用 URLProtocol 建立可重現的 iOS API 離線回歸測試

遠端建置一旦依賴真實測試 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.jsonprojects-empty.jsonprojects-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 mini

依任務長度選擇雲端 Mac 租用週期

兩種 M4 設定可按日、週、月或季租用;節點與實際可用資訊以控制台即時回傳內容為準。

選擇設定並訂購