云端 Mac 上的 iOS 启动参数与环境变量契约测试

云端 Mac 上的 iOS 启动参数与环境变量契约测试

UI 测试在开发者电脑上通过,放到云端 Mac 连跑几十次后却偶尔停在引导页,常见原因并不是模拟器性能,而是启动状态没有形成契约。某个用例传了 -skipOnboarding,另一个改用环境变量,第三个忘记清理持久化数据;单次执行都合理,并行或换序后就开始互相污染。解决办法是把参数名、值格式、状态重置和 Release 排除条件当成一套可测试接口。

先定义启动契约而不是散落字符串

启动参数适合布尔开关,环境变量适合带值配置。两者都应集中声明,避免测试目标与应用目标各自拼写。下面的解析器只在 Debug 构建中生效,并且对缺值、空值采用明确默认值。

#if DEBUG
enum UITestLaunchContract {
    static let arguments = ProcessInfo.processInfo.arguments
    static let environment = ProcessInfo.processInfo.environment

    static var enabled: Bool {
        arguments.contains("-ui-testing")
    }

    static var resetState: Bool {
        arguments.contains("-reset-state")
    }

    static var fixture: String? {
        guard enabled else { return nil }
        return environment["UITEST_FIXTURE"]?.trimmingCharacters(
            in: .whitespacesAndNewlines
        ).nonEmpty
    }
}

private extension String {
    var nonEmpty: String? {
        isEmpty ? nil : self
    }
}
#endif

契约至少要回答四件事:开关叫什么、值从哪里读取、非法值如何处理、非测试启动会发生什么。不要让“没传值”隐式等于某个业务状态,也不要把访问凭据、签名材料或令牌放进环境变量;它们可能出现在进程信息、测试报告或失败日志中。

测试入口的目标是制造可重复状态,不是绕过业务校验。能由公开界面完成的断言仍应走公开界面,只有初始化夹具和清理残留适合放进启动契约。

在应用启动早期完成状态隔离

状态重置必须发生在读取用户偏好、创建数据库容器和决定首屏之前。若先构建界面再异步清理,测试会同时观察到旧状态与新状态,形成难复现的竞态。

只清理测试拥有的数据

为 UI 测试使用独立 suite,而不是执行无边界的目录删除。数据库也应放进测试专属容器,并在创建持久化栈前移除。

#if DEBUG
func prepareUITestState() throws {
    guard UITestLaunchContract.enabled else { return }

    if UITestLaunchContract.resetState {
        let defaults = UserDefaults(suiteName: "com.example.app.uitests")
        defaults?.removePersistentDomain(
            forName: "com.example.app.uitests"
        )

        let storeURL = FileManager.default.temporaryDirectory
            .appendingPathComponent("UITestStore.sqlite")
        if FileManager.default.fileExists(atPath: storeURL.path) {
            try FileManager.default.removeItem(at: storeURL)
        }
    }
}
#endif

清理函数应可重复执行:目标不存在时不报错,删除失败时立即终止测试启动,而不是带着半清理状态继续。夹具名称也应采用白名单映射,例如 empty-projectthree-items,不要允许环境变量直接变成任意文件路径。

由 XCTest 统一组装启动参数

测试侧只调用一个启动工厂。它负责终止旧进程、写入固定参数并检查夹具,测试方法不再自行修改 launchArguments

final class AppLauncher {
    static func launch(fixture: String) -> XCUIApplication {
        let allowed = ["empty-project", "three-items"]
        precondition(allowed.contains(fixture))

        let app = XCUIApplication()
        if app.state != .notRunning {
            app.terminate()
        }

        app.launchArguments = [
            "-ui-testing",
            "-reset-state",
            "-disable-animations"
        ]
        app.launchEnvironment = [
            "UITEST_FIXTURE": fixture,
            "LC_ALL": "en_US_POSIX"
        ]
        app.launch()
        return app
    }
}

launchArguments 应整体赋值,不要在共享应用实例上连续 append。整体赋值能保证重跑时没有上一条用例留下的开关。若测试需要两个不同状态,应终止进程后重新调用工厂,而不是在运行中修改环境变量;进程启动后,ProcessInfo 不会按测试脚本的新值重新初始化。

把契约错误变成流水线失败

仅靠代码审查挡不住旧参数。可以增加一个小型单元测试,验证参数名唯一、夹具均可解析,并让 UI 冒烟测试覆盖三条路径:无参数正常启动、合法夹具进入预期首屏、非法夹具明确失败。

在云端 Mac 上执行前,先固定 scheme、测试计划与模拟器标识:

set -euo pipefail

: "${SIMULATOR_ID:?SIMULATOR_ID is required}"

xcodebuild test \
  -workspace App.xcworkspace \
  -scheme App \
  -testPlan CI \
  -destination "platform=iOS Simulator,id=${SIMULATOR_ID}" \
  -resultBundlePath artifacts/LaunchContract.xcresult

流水线不应用“再跑一次”掩盖首次失败。首次失败的结果包要原样保存,重试只能作为诊断步骤,并使用新的输出路径。否则第二次成功会覆盖最有价值的启动现场。

检查 Release 编译条件

测试入口使用 #if DEBUG 后,还要确认 Release 没有误带 DEBUG 或自定义测试条件:

set -euo pipefail

settings="$(
  xcodebuild \
    -workspace App.xcworkspace \
    -scheme App \
    -configuration Release \
    -showBuildSettings
)"

if printf '%s\n' "$settings" |
  grep -E 'SWIFT_ACTIVE_COMPILATION_CONDITIONS.*(DEBUG|UI_TESTING)'; then
  echo "Unexpected test compilation condition in Release" >&2
  exit 1
fi

这项检查针对最终解析后的构建设置,比只查看工程文件可靠。若团队使用多套 xcconfig,应对每个可发布 configuration 分别执行。

处理并行执行与失败现场

并行测试时,每个 worker 必须有独立的数据标识。可以由流水线生成非敏感的 UITEST_RUN_ID,再组合出临时目录和 suite 名;不要让多个 worker 共用固定数据库文件。测试完成后只删除当前运行标识对应的目录,避免一条任务清理另一条任务的现场。

失败证据至少保留启动参数白名单、夹具名、测试方法、模拟器标识和结果包路径。日志中不要输出完整环境变量字典,因为其中可能混入流水线注入的敏感值。建议仅记录经过允许的键,并把值按类型脱敏。

排查顺序可以固定为:

  1. 确认应用是否在启动前真正终止。
  2. 检查参数是否整体赋值且没有重复键。
  3. 核对状态清理是否早于数据库和根界面初始化。
  4. 比较失败用例与前一用例使用的数据目录。
  5. 用同一结果包检查崩溃、断言和截图时间线。
  6. 不带任何测试参数执行一次冷启动,确认正常入口未受影响。

上线前的最小验收清单

提交合并前,应同时满足:参数名集中定义;带值配置有白名单;测试数据位于独立容器;清理操作可重复;每次启动整体覆盖参数;并行 worker 不共享目录;失败结果包不会被重试覆盖;Release 编译条件不含测试标记;无参数冷启动能够进入正常流程。

这套约束的价值不在于多写一个启动工厂,而在于让“应用以什么状态启动”从测试脚本里的隐含约定,变成应用与流水线都能验证的接口。状态一旦可描述、可拒绝、可清理,云端 Mac 上的换序执行、并行执行和长期重复执行才会给出可比较的结果。

常见问题

为什么不能直接在 UI 测试里随意追加启动参数?

未集中定义的参数容易出现拼写分叉、顺序依赖和旧开关残留。应由单一类型声明参数名、值格式和默认行为,测试目标与应用目标共同使用这份契约。

怎样确认测试开关没有进入 Release 构建?

将测试入口限制在 DEBUG 条件编译中,并在流水线检查 Release 的编译条件和最终构建设置;同时用不含测试参数的冷启动冒烟测试验证正常路径。

云端 Mac

把验证过的工作流放到独享物理节点

按任务选择 M4 或 M4 Pro、目标节点与计费周期,在下单前核对配置和附加选项。

选择配置并下单