背景重新整理在開發用 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、記憶體、儲存空間、節點與計費週期,再將固定建置環境接入現有工作流程。