云端 Mac 上的 iOS 后台任务回归测试实战

云端 Mac 上的 iOS 后台任务回归测试实战

后台刷新在开发机上偶尔成功,接入 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 保留在薄适配层中,将同步、清理或上传逻辑抽成可注入的异步任务,使测试能够直接控制输入、时钟、取消和失败结果。

DEDICATED BUILD SLOT

为持续构建任务选择独享云端 Mac

核对 M4、内存、存储、节点和计费周期,再把固定构建环境接入现有流水线。

选择配置并订购