同一组 iOS 界面测试在开发机通过,搬到云端 Mac 后却偶尔卡在权限提示框,通常不是业务代码随机失效,而是模拟器残留了上一次任务的 TCC 权限状态。有人点过“允许”,后续用例就看不到首次询问;有人点过“拒绝”,依赖联系人或照片的流程又会直接走失败分支。稳定测试的关键不是更频繁地重试,而是在每个用例开始前明确声明权限前置条件。
把权限状态当作测试输入
权限测试至少要覆盖三种状态:尚未决定、已经允许、已经拒绝。它们对应不同的产品路径,不能用一个“重装应用”步骤混在一起。
| 目标状态 | simctl 动作 | 应验证的行为 |
|---|---|---|
| 尚未决定 | reset |
应用发起请求,系统显示权限提示 |
| 已经允许 | grant |
功能直接继续,不重复询问 |
| 已经拒绝 | revoke |
应用展示降级说明或设置引导 |
先确认运行时支持目标服务。不同系统版本可控制的服务可能不同,因此不要把服务名凭经验写死后直接提交:
xcrun simctl privacy help
xcrun simctl list devices available
常见服务包括 contacts、photos、photos-add、location、microphone 和 calendar。测试脚本应以当前 Xcode 所带 simctl 的帮助输出为准。
权限状态属于模拟器设备,不属于代码仓库。只清理构建目录不会清理 TCC 数据,删除并重新安装应用也不应被当作可靠的权限重置方案。
准备专用模拟器与应用
CI 不要随意使用 booted 指向任意已启动设备。并行任务可能同时找到同一台模拟器,导致安装、启动和权限修改互相覆盖。更稳妥的做法是为任务分配明确的 UDID,并把它作为脚本参数传入。
应用必须先安装,grant 或 revoke 才能针对真实的 Bundle Identifier 生效。Bundle Identifier 也不要从 Scheme 名推断,可直接从构建产物读取:
APP_PATH="$1"
UDID="$2"
BUNDLE_ID=$(/usr/libexec/PlistBuddy -c "Print :CFBundleIdentifier" \
"$APP_PATH/Info.plist")
xcrun simctl boot "$UDID" 2>/dev/null || :
xcrun simctl bootstatus "$UDID" -b
xcrun simctl install "$UDID" "$APP_PATH"
printf '%s
' "$BUNDLE_ID"
如果测试的是扩展,扩展拥有独立的 Bundle Identifier。应分别读取扩展的 Info.plist,不要把主应用标识用于所有权限命令。
用脚本固定三种前置状态
把状态切换集中到一个函数,比散落在流水线配置中更容易审查。修改权限前先终止应用,避免进程仍持有旧状态或正在显示提示框。
set -euo pipefail
UDID="$1"
BUNDLE_ID="$2"
SERVICE="$3"
STATE="$4"
xcrun simctl terminate "$UDID" "$BUNDLE_ID" 2>/dev/null || :
case "$STATE" in
undecided)
xcrun simctl privacy "$UDID" reset "$SERVICE" "$BUNDLE_ID"
;;
authorized)
xcrun simctl privacy "$UDID" grant "$SERVICE" "$BUNDLE_ID"
;;
denied)
xcrun simctl privacy "$UDID" revoke "$SERVICE" "$BUNDLE_ID"
;;
*)
printf 'Unknown permission state: %s
' "$STATE" >&2
exit 64
;;
esac
执行后再启动应用或运行测试。比如联系人已允许场景可以先调用脚本,再只运行对应测试类:
./set-permission.sh "$UDID" "$BUNDLE_ID" contacts authorized
xcodebuild test \
-scheme PermissionTests \
-destination "platform=iOS Simulator,id=$UDID" \
-only-testing:PermissionUITests/ContactsAuthorizedTests \
-resultBundlePath Artifacts/ContactsAuthorized.xcresult
reset 表示回到尚未决定,而不是拒绝。若业务代码把这两种状态合并处理,测试应直接暴露这个设计问题。
分开测试系统提示与业务分支
系统提示属于系统界面,按钮定位容易受到语言、运行时版本和提示顺序影响。不要让所有权限测试都依赖点击提示框。更合理的分层是:多数测试通过 grant 与 revoke 验证业务分支,只保留少量用例验证首次请求。
首次请求用例
先执行 reset,启动应用并触发权限请求。用例只检查提示是否出现、用户选择后应用是否进入预期页面。若需要操作系统界面,应使用独立的系统应用对象,并避免依赖固定坐标。
已允许与已拒绝用例
这两类用例不应看到提示框。已允许状态验证功能正常读取数据;已拒绝状态验证应用不会循环请求,也不会停留在无限加载。设置引导若存在,应只验证按钮与说明,不在自动化任务中实际修改系统设置。
位置权限还要区分应用使用期间与持续授权等业务语义。simctl privacy 能准备基础状态,但无法代替所有真实设备行为,因此相关结论应保留真机验收。
隔离、取证与失败排查
每个测试类结束后可对对应服务执行 reset,但更重要的是让下一条测试自行建立状态,而不是依赖上一条测试清理成功。并行执行时,一台模拟器只交给一个任务;需要并发就创建多台设备,不要共享 UDID。
失败时至少保存 .xcresult、测试日志、模拟器系统日志和当前设备列表。若预期状态没有生效,按以下顺序检查:
- Bundle Identifier 是否来自实际安装的应用或扩展。
- 服务名是否被当前运行时支持。
- 应用是否在修改状态前被终止。
- 目标 UDID 是否与
xcodebuild使用的设备一致。 - 是否存在另一个并行任务操作同一台模拟器。
- 测试代码是否缓存了旧的授权结果。
不要把“抹掉整台模拟器”设成默认修复。它会显著增加任务时间,还可能掩盖状态管理缺陷。只有设备持续出现无法解释的系统状态时,才重建该设备并保留此前日志。
在 MiniRent 的云端 Mac 上运行这类任务时,机型和节点只影响任务放置方式;测试脚本仍应通过 UDID、Bundle Identifier、服务名和目标状态完整描述环境,并在控制台确认当前可选配置。这样,同一套权限回归既能在临时调试任务中复现,也能稳定进入持续集成。
常见问题
simctl privacy 能完全替代系统权限弹窗测试吗?
不能。它适合准备未决定、允许和拒绝状态;首次弹窗的文案、按钮与跳转仍应保留少量端到端界面测试。
每个权限用例前都需要抹掉整个模拟器吗?
通常不需要。先终止应用,再针对应用标识执行 reset、grant 或 revoke;只有系统状态已经不可解释时才重建模拟器。
用云端 Mac mini 运行下一项开发或构建任务
从两档 M4 配置、四种租用周期和五个在售节点中选择,实际可用状态以控制台实时返回为准。