WWDC Quick Look 💓 By SwiftGGTeam
Get your test results faster

Get your test results faster

观看原视频

Highlight

Xcode 12 为 XCTest 增加 Execution Time Allowance 和物理设备上的 Parallel Distributed Testing,让挂起测试生成 spindump 并继续执行剩余套件,也让 xcodebuild 把测试类分发到多台 iOS 或 tvOS 设备上并行运行。

核心内容

测试反馈循环很简单:写测试,运行测试,阅读报告,再决定继续修代码、补测试或发布功能。问题在于,循环最容易卡在“运行测试”这一步。周五下班前启动的 CI,周一早上还没结束;报告只说任务被取消,团队既拿不到结果,也不知道哪一段代码把测试挂住了。

过去遇到这种长时间无响应,只能猜。可能是死锁,可能是主线程 CPU 工作太多,可能是某个 timeout 设得太宽。猜测会把测试维护变成考古。Xcode 12 的 Execution Time Allowance 把每个测试的最长运行时间变成配置项:超时后,Xcode 先抓取 spindump,再结束挂起测试,重启 test runner,让剩余测试继续跑完。

拿到完整报告以后,下一个痛点是速度。Fruita 示例里,十几个测试串行跑完要接近 13 分钟。它们耗时分布不均,有的几百毫秒,有的几分钟。Xcode 12 扩展了 Parallel Distributed Testing:xcodebuild 可以把测试类分发到多个 run destination。每台设备跑完一个类后继续领取下一个类,直到没有测试可分配。

这场 session 讨论的“快”有明确顺序:先保证测试不会无限卡住,再用 spindump 指向挂起位置,最后把适合拆分的测试并行分发。这样,CI 报告从“没有结果”变成“有失败、有诊断、有总耗时”,团队才能根据结果行动。

详细内容

1. 用 Execution Time Allowance 处理挂起测试

03:56)Xcode 12 的新 test plan option 叫 Execution Time Allowance。开启后,Xcode 会限制每个独立测试的运行时间。测试超过限制时,Xcode 抓取 spindump,杀掉挂起测试,重启 test runner,然后继续运行套件里的其他测试。

import XCTest
@testable import Fruta

class SmoothieNetworkingTests: XCTestCase {
    func testUpdatingSmoothiesFromServer() throws {
        let originalSmoothies = Smoothie.all
        try Smoothie.fetchSynchronouslyFromServer()
        XCTAssertNotEqual(originalSmoothies, Smoothie.all)
    }
}

关键点:

  • SmoothieNetworkingTests 继承 XCTestCase,这是被 Xcode 执行和计时的测试类。
  • testUpdatingSmoothiesFromServer() 是 session demo 中会挂起的测试方法。
  • let originalSmoothies = Smoothie.all 记录网络更新前的数据。
  • try Smoothie.fetchSynchronouslyFromServer() 调用待测同步网络更新逻辑,挂起点就在这条调用链里。
  • XCTAssertNotEqual(originalSmoothies, Smoothie.all) 验证服务端数据是否改变了本地 smoothie 列表。

04:18)spindump 会附加到 test report。它展示每个线程主要停在哪些函数上,比“CI 被取消”更可执行。session 还指出,spindump 也可以从 Terminal 的 spindump 命令或 Activity Monitor 手动获取。

2. 从 spindump 定位死锁

07:56)demo 生成报告后,spindump 里能搜索到测试名 testUpdatingSmoothiesFromServer。调用栈显示测试进入 fetchSynchronouslyFromServer(),再进入 performGETRequest(to:),最后停在锁等待上。这比查看整套业务代码更快。

SmoothieNetworkingTests.testUpdatingSmoothiesFromServer()
  static Smoothie.fetchSynchronouslyFromServer()
    static Smoothie.performGETRequest(to:)
      psynch_mtxcontinue

关键点:

  • 第一行把挂起现象绑定到具体 XCTest 方法。
  • 第二行说明测试已经进入待测 API,而不是卡在测试框架外层。
  • 第三行把范围缩小到 helper method performGETRequest(to:)
  • psynch_mtxcontinue 表示线程正在等待互斥锁,和 transcript 中的 deadlock 分析吻合。

08:23)源码显示,fetchSynchronouslyFromServer() 已经加锁,内部调用的 performGETRequest(to:) 又尝试获取同一把锁。这个 helper 只是执行 GET 请求,不应该重复获取这把锁。

extension Smoothie {

    enum Errors: Error {
        case noData
    }

    static var serverIsAvailable: Bool { false }
    static var smoothieEndpoint: URL {
        URL(string: "https://smoothies.food.com")!
    }

    static func fetchSynchronouslyFromServer() throws {
        fetchSmoothieLock.lock()
        defer { fetchSmoothieLock.unlock() }

        guard let data = performGETRequest(to: smoothieEndpoint) else {
            throw Errors.noData
        }

        let smoothies = try JSONDecoder().decode([Smoothie].self, from: data)
        Smoothie.all += smoothies
    }

    static func performGETRequest(to url: URL) -> Data? {
        defer { fetchSmoothieLock.unlock() }

        if url == smoothieEndpoint {
            fetchSmoothieLock.lock()
        }

        return performNetworkRequest(method: .get, url: url)
    }
}

关键点:

  • fetchSynchronouslyFromServer() 先调用 fetchSmoothieLock.lock(),并用 defer 安排退出时解锁。
  • performGETRequest(to:) 内部又在 URL 等于 smoothieEndpoint 时加锁。
  • 同一条调用链重复争用这把锁,spindump 中的等待栈就有了具体原因。
  • 这个代码片段说明 Execution Time Allowance 的价值:它不只结束挂起测试,还把定位材料写进报告。

08:43)修复方式是移除 helper method 中的锁逻辑,让它只负责网络请求。测试重新运行后立即结束,挂起被消除。

extension Smoothie {

    // Omitted for brevity. See previous code snippet for content.

    static func performGETRequest(to url: URL) -> Data? {
        return performNetworkRequest(method: .get, url: url)
    }
}

关键点:

  • performGETRequest(to:) 保留原来的职责:发起 GET 请求并返回 Data?
  • 锁的获取和释放不再分散到 helper method 中。
  • 上层 fetchSynchronouslyFromServer() 仍然可以控制数据更新的临界区。
  • 修复后,测试反馈循环从“卡住并取消”变成“运行并给出结果”。

3. 配置默认和最大 time allowance

05:13)默认情况下,每个测试有 10 分钟 allowance。某个测试在 10 分钟内完成后,计时器会为下一个测试重置。全局默认值可以在 test plan configuration 中调整;特定 test 或 test class 可以用 executionTimeAllowance API 单独设置。

XCTestCase.executionTimeAllowance -> TimeInterval
values are rounded up to the nearest minute

关键点:

  • executionTimeAllowanceXCTestCase 上的 TimeInterval property,transcript 明确把它作为特定 test 或 test class 的配置入口。
  • 返回值代表更细粒度的运行时间上限,不需要覆盖全局 test plan 默认值。
  • transcript 说明 allowance 会按分钟取整;低于 60 秒会向上取到 60 秒,100 秒会向上取到 120 秒。
  • API 级设置的优先级最高,适合少数确实需要更长时间的测试。

09:18)配置有优先级:executionTimeAllowance API 最高,xcodebuild 的 time allowance option 其次,test plan setting 再其次,系统默认 10 分钟最低。为了避免测试请求无限时间,Xcode 12 还提供 maximum allowance,可以在 test plan 或 xcodebuild 里强制上限。

4. 用 Parallel Distributed Testing 缩短总耗时

12:21)Parallel Distributed Testing 会按 class 把测试分发到各个 run destination。每台设备一次运行一个测试类,跑完后继续获取下一个类。Xcode 12 的新增点是:xcodebuild 可以在物理 iOS 和 tvOS 设备上并行运行测试。

parallel-testing-enabled = YES
parallelize-tests-among-destinations = YES

关键点:

  • parallel-testing-enabled 打开并行测试能力。
  • parallelize-tests-among-destinations 要求 xcodebuild 把测试分散到指定 destination,而不是在每个 destination 上跑完整套测试。
  • transcript 明确说 Xcode 12 新增的是通过 xcodebuild 在物理 iOS 和 tvOS 设备上并行运行测试。
  • 参与分发的 destination 应该由 CI 环境提供,session 建议优先使用同型号、同 OS 版本的设备池。

12:47)测试类分配到 destination 的过程是不确定的。测试如果依赖设备型号或 OS 版本,分发后可能出现难复现的失败或 skip。session 的建议是使用设备和系统版本一致的 device pool;如果池子混合了设备和 OS,只分发对 destination 不敏感的测试,例如纯业务逻辑 framework 的测试。

14:50)如果目标是验证同一套代码在更多 OS 和设备上都能工作,应该使用 Parallel Destination Testing。它会在每个 destination 上运行完整测试套件,不会把单个套件拆到不同 destination 上。

核心启发

  • 做什么:给 CI 的长跑测试套件开启 Execution Time Allowance。 为什么值得做:挂起测试会让 result bundle 没有有效结论,session 展示了超时后抓 spindump 并继续执行剩余测试的流程。怎么开始:在 test plan 中打开 test time-outs,先使用默认 10 分钟,再根据历史耗时调整。
  • 做什么:为已知慢测试设置单独 allowance。 为什么值得做:全局 allowance 过大,会拖慢发现挂起测试的时间;过小,会误伤确实需要更久的测试。怎么开始:在对应 XCTestCase 上覆盖 executionTimeAllowance,并在 test plan 或 xcodebuild 中配置 maximum allowance。
  • 做什么:把 spindump 纳入测试失败排查流程。 为什么值得做:demo 用 spindump 从测试名一路定位到 performGETRequest(to:) 的锁等待。怎么开始:保留 CI 的 result bundle,遇到 allowance failure 时先打开附件,搜索测试方法名和业务方法名。
  • 做什么:为纯业务逻辑测试建立并行设备池。 为什么值得做:session 说明分发算法按测试类给 destination 派工,适合不依赖设备差异的测试。怎么开始:准备同型号、同 OS 版本设备,在 CI 的 xcodebuild test 命令中添加多个 -destination 和两个 parallel flag。
  • 做什么:把“分发测试”和“多系统验证”拆成两条 CI job。 为什么值得做:Parallel Distributed Testing 追求缩短套件耗时,Parallel Destination Testing 追求覆盖多个 OS 或设备。怎么开始:一条 job 使用 identical device pool 做分发,另一条 job 在目标 OS 矩阵上跑完整套测试。

关联 Session

评论

GitHub Issues · utterances