雲端 Mac 上的 iOS 背景任務迴歸測試實作

雲端 Mac 上的 iOS 背景任務迴歸測試實作

背景重新整理在開發用 Mac 上偶爾成功,接入 CI 後卻經常變成「沒有執行」。問題通常不在任務程式碼,而是測試把系統排程時間當成了確定條件。iOS 何時喚醒行程由系統決定,雲端 Mac 也無法將它變成固定計時器。可靠的做法是拆開三件事:靜態註冊設定、排程適配層,以及真正執行同步或清理的業務邏輯。

先定義可驗證的邊界

一條背景任務鏈路至少包含註冊、提交請求、系統回呼、執行工作、到期取消及結果回報。持續整合應穩定驗證前後兩端,而不應等待系統「碰巧」觸發。

層級 自動化檢查 不應作為斷言
設定層 識別碼、能力宣告、目標套件設定 系統何時喚醒
適配層 註冊成功、回呼轉送、完成狀態 排程優先順序
任務層 輸出、錯誤、取消、重複執行 真實電量與使用習慣

迴歸測試的目標不是證明任務會在某一分鐘啟動,而是證明系統一旦交付任務,應用程式就能正確完成、取消或安全重試。

先固定任務識別碼,例如 com.example.app.refresh。它必須同時出現在註冊程式碼與 BGTaskSchedulerPermittedIdentifiers 中。若不同建置設定會產生不同的 Info.plist,應檢查建置產物,而不是只讀取儲存庫裡的原始檔案。

將排程器壓縮成薄適配層

不要直接在 BGAppRefreshTask 回呼中撰寫網路、資料庫及快取邏輯。先定義一個可替換的執行單元:

protocol RefreshJob {
    func run() async throws
    func cancel()
}

final class BackgroundRefreshAdapter {
    private let job: RefreshJob

    init(job: RefreshJob) {
        self.job = job
    }

    func handle(task: BGAppRefreshTask) {
        task.expirationHandler = { [job] in
            job.cancel()
        }

        Task {
            do {
                try await job.run()
                task.setTaskCompleted(success: true)
            } catch {
                task.setTaskCompleted(success: false)
            }
        }
    }
}

再將網路用戶端、儲存介面與時鐘注入實際任務。如此一來,單元測試不必偽造 BGAppRefreshTask,只需驗證 RefreshJob 在給定輸入下會產生什麼輸出。適配層應保持精簡,其責任只有轉送執行、處理到期及回報完成狀態。

讓取消真正傳遞到最底層

只設定布林變數通常不夠。下載、解析及批次寫入之間都應檢查取消狀態;使用 Swift 並行處理時,可在各階段邊界呼叫 Task.checkCancellation()。資料庫寫入宜採用短交易,避免任務到期後仍持有長交易,或留下不完整的資料。

先建立靜態設定閘門

背景任務最常見的失敗原因是識別碼拼錯、能力未納入目標設定,或測試讀取了錯誤的 plist。建置完成後,直接檢查應用程式套件:

set -euo pipefail

APP_PATH="$BUILT_PRODUCTS_DIR/$WRAPPER_NAME"
PLIST="$APP_PATH/Info.plist"
TASK_ID="com.example.app.refresh"

plutil -extract BGTaskSchedulerPermittedIdentifiers raw "$PLIST" |
  grep -Fx "$TASK_ID"

plutil -extract UIBackgroundModes raw "$PLIST" |
  grep -F "fetch"

腳本應作為獨立建置階段執行,並明確宣告相依的輸入檔案,以免每次都無條件執行。若使用處理型任務,還要檢查對應的背景模式;不要因為兩種任務都透過同一個框架註冊,就假設其宣告完全相同。

註冊程式碼也應傳回可觀察的結果。啟動時將每個識別碼的註冊結果寫入應用程式內部診斷記錄,測試讀取記錄並斷言全部成功。記錄中只保留任務識別碼、階段及錯誤類型,不要寫入存取權杖或完整請求內容。

涵蓋成功、到期與重複執行

業務任務至少需要四組測試。第一組使用固定回應,驗證成功後游標、快取及更新時間會以原子方式提交。第二組在下載或寫入階段注入錯誤,確認舊資料仍可讀取。第三組觸發取消,確認暫存檔案已清理,且不會更新成功標記。第四組以相同輸入連續執行兩次,確認不會產生重複記錄。

func testRepeatedRunIsIdempotent() async throws {
    let store = InMemoryStore()
    let client = StubClient(items: [.init(id: "42")])
    let job = SyncRefreshJob(client: client, store: store)

    try await job.run()
    try await job.run()

    XCTAssertEqual(store.items.map(\.id), ["42"])
    XCTAssertEqual(store.commitCount, 2)
}

冪等不代表第二次什麼都不做,而是要求最終狀態一致,且重複提交不會增加重複物件。若任務會上傳檔案,可使用穩定的業務鍵記錄已提交狀態;若任務採用分頁游標,則應測試「資料已寫入但游標尚未更新」的中斷點。

不要把休眠等待寫進測試

避免使用固定的 sleep 猜測非同步作業何時完成。將時鐘與退避策略抽象成協定,並在測試時使用可手動推進的時鐘。輪詢必須有明確的截止條件,失敗訊息也應輸出目前階段,而不是只回報逾時。

在雲端 Mac 上封存可複查的證據

先列出目前節點可用的模擬器,再選擇專案已安裝的執行階段:

xcrun simctl list devices available

xcodebuild test \
  -scheme BackgroundTasks \
  -destination 'platform=iOS Simulator,OS=latest,name=iPhone 16' \
  -resultBundlePath artifacts/BackgroundTasks.xcresult

裝置名稱應由管線變數提供,避免 Xcode 更新後,腳本仍綁定到不存在的執行階段。失敗時應保留 xcresult、測試記錄、應用程式診斷記錄及所使用的提交編號;不要只上傳主控台最後幾十行。

除錯階段可以透過 Xcode 的背景任務除錯功能手動觸發系統回呼,但這類入口只能用來確認適配層接線,不可成為發行版本的相依項目。最終驗收分成兩層:CI 以確定性方式驗證設定及任務邏輯,受控裝置則驗證系統交付任務後的真實生命週期。兩層結果應分開記錄,失敗時才能判斷是設定迴歸、業務錯誤,還是對系統排程時機抱持了錯誤預期。

常見問題

iOS 模擬器可以證明背景任務會準時啟動嗎?

不可以。模擬器可驗證註冊、任務邏輯、取消與冪等性,但系統喚醒時機不是可供 CI 固定斷言的條件。

如何讓背景任務測試保持穩定?

將 BGTaskScheduler 限縮為薄配接層,並把實際工作抽成可注入的非同步元件,測試時直接控制時鐘、輸入、錯誤與取消。

專用建置時段

為持續建置工作選擇獨享雲端 Mac

確認 M4、記憶體、儲存空間、節點與計費週期,再將固定建置環境接入現有工作流程。

選擇配置並訂購