后台刷新在开发机上偶尔成功,接入 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 模拟器能否验证后台任务一定会按时启动?
不能。模拟器适合验证注册配置、任务逻辑、取消和幂等性,但系统实际唤醒时间由 iOS 决定,不能作为确定性 CI 断言。
后台任务测试最重要的设计改动是什么?
把 BGTaskScheduler 保留在薄适配层中,将同步、清理或上传逻辑抽成可注入的异步任务,使测试能够直接控制输入、时钟、取消和失败结果。
为持续构建任务选择独享云端 Mac
核对 M4、内存、存储、节点和计费周期,再把固定构建环境接入现有流水线。