WWDC Quick Look 💓 By SwiftGGTeam
Record, replay, and review: UI automation with Xcode

Record, replay, and review: UI automation with Xcode

观看原视频

Highlight

Xcode 现在可以一键把你的点击操作录制成 XCUITest 代码,并在 Xcode Cloud 上跨多语言、多设备、多系统状态回放,最后用带视频的 Test Report 定位失败。


核心内容

很多开发者不写 UI 测试,原因很简单:手写 XCUIElementQuery 链路太啰嗦,跨语言、跨设备验证又只能靠人肉点。一个简单的”添加收藏”流程,要在英语、阿拉伯语、德语下分别跑一遍,再在 iPhone、iPad 上各跑一遍——成本高到没人愿意做。

Apple 的 Xcode 团队给出的解法是把 UI 自动化拆成三个阶段:录制(Record)、回放(Replay)、审查(Review)。录制阶段你只管点,Xcode 把每一次点击、滑动、键盘输入翻译成 Swift 代码(12:00);回放阶段用 Test Plan 把同一段测试映射到多个 locale 和设备配置,本地或 Xcode Cloud 都能跑;审查阶段 Test Report 提供完整视频回放,失败时刻有 diamond 标记,点元素可以直接 View Source 跳到代码(03:00)。

底层逻辑值得记住:UI 自动化建立在 accessibility 框架之上(05:02)。accessibilityIdentifier 是你给自动化的”稳定主键”——它不本地化、不读屏,只供测试代码定位元素。先把无障碍做好,UI 测试自然稳定。


详细内容

一、给视图加 accessibility identifier

SwiftUI 用 .accessibilityIdentifier 修饰符,配合数据模型的 id 让每个 cell 唯一(07:52):

import SwiftUI

struct LandmarkDetailView: View {
  let landmark: Landmark
  var body: some View {
    VStack {
      Image(landmark.backgroundImageName)
        .accessibilityIdentifier("LandmarkImage-\(landmark.id)")

      Text(landmark.description)
        .accessibilityIdentifier("LandmarkDescription-\(landmark.id)")
    }
  }
}

关键点:

  • LandmarkImage-\(landmark.id):identifier 拼上数据 id,保证在列表里每个 cell 都唯一。
  • 不要把 identifier 写成 "Great Barrier Reef" 这种用户可见文案,那种字符串会随本地化变化。
  • identifier 的三个原则是:unique(在整个 app 里唯一)、descriptive(描述清楚)、static(不随内容变化)。

UIKit 端用属性赋值(08:19):

import UIKit

struct LandmarksListViewController: UIViewController {
  let landmarks: [Landmark] = [landmarkGreatBarrier, landmarkCairo]

  override func viewDidLoad() {
    super.viewDidLoad()

    for landmark in landmarks {
      let button = UIButton(type: .custom)
      setupButtonView()

      button.accessibilityIdentifier = "LandmarkButton-\(landmark.id)"

      view.addSubview(button)
    }
  }
}

关键点:

  • accessibilityIdentifierUIView 自带属性,对默认就是 accessibility element 的控件(按钮、文本、图片)直接赋值即可。
  • 它不会被 VoiceOver 朗读,也不会暴露给用户,是给自动化”私用”的字段。
  • 想偷懒?Xcode 的 coding assistant 支持 “Add accessibility identifiers to the relevant parts of this view” 这种自然语言指令,会自动用模型的 id 字段去拼 identifier(09:05)。

二、录制后立刻重写查询

录制器给每行代码一个 dropdown,列出多种查询方式。Max 给了三条选择原则(13:54):

// 反例:用本地化字符串
XCUIApplication().staticTexts["Max's Australian Adventure"]

// 正例:用 accessibility identifier
XCUIApplication().staticTexts["Collection-1"]

关键点:

  • 本地化字符串在阿拉伯语、德语下会变,identifier 不会。
  • staticTexts[...] 是按 identifier 查找的语法糖,匹配的是你在 SwiftUI 里写的 .accessibilityIdentifier

第二条:查询越短越好(14:09):

// 反例:链路太长
XCUIApplication().scrollViews.staticTexts["Collection-1"]

// 正例:直接定位
XCUIApplication().staticTexts["Collection-1"]

关键点:

  • 中间多一层 .scrollViews,等于把”必须在 ScrollView 里”也写死了;如果以后改成 List 或 Form,测试就崩。
  • identifier 在整个 app 里唯一时,跳过中间容器是安全的。

第三条:动态内容用通用查询(14:21):

// 反例:写死具体内容
XCUIApplication().staticTexts["Max's Australian Adventure"]

// 正例:取第一个匹配
XCUIApplication().staticTexts.firstMatch

关键点:

  • firstMatch 适合”这里应该有一个文本,但具体内容会变”的场景。

三、加验证、设设备状态、传启动参数

录制完只是第一步,要补 XCTAssertwaitForExistence15:49):

import XCTest

class LandmarksUITests: XCTestCase {

  func testGreatBarrierAddedToFavorites() {
    let app = XCUIApplication()
    app.launch()
    app.cells["Landmark-186"].tap()
    XCTAssertTrue(
      app.staticTexts["Landmark-186"].waitForExistence(timeout: 10.0)),
      "Great Barrier exists"
    )

    let favoriteButton = app.buttons["Favorite"]
    favoriteButton.tap()
    XCTAssertTrue(
      favoriteButton.wait(for: \.value, toEqual: true, timeout: 10.0),
      "Great Barrier is a favorite"
    )
  }
}

关键点:

  • waitForExistence(timeout:) 等元素出现,避免动画/网络延迟引起的 flaky test。
  • wait(for: \.value, toEqual: true, timeout:) 是新的 keypath 等待 API,能等任意属性变成期望值,比死循环 sleep 干净得多。
  • 收藏按钮的 value 在被选中后变 true,这就是无障碍框架暴露的状态字段。

设备状态可以在 setUp 里一次性设好(16:36):

import XCTest
import CoreLocation

class LandmarksUITests: XCTestCase {

  override func setUp() {
    continueAfterFailure = false

    XCUIDevice.shared.orientation = .portrait
    XCUIDevice.shared.appearance = .light

    let simulatedLocation = CLLocation(latitude: 28.3114, longitude: -81.5535)
    XCUIDevice.shared.location = XCUILocation(location: simulatedLocation)
  }
}

关键点:

  • XCUIDevice.shared.appearance 切换浅色/深色,省得手动开 Dark Mode。
  • XCUIDevice.shared.location = XCUILocation(location:) 给 Simulator 注入虚拟定位,地图、附近搜索类 app 用得上。
  • continueAfterFailure = false 保证一旦断言挂掉就立刻停下,不要在错误状态上继续点。

启动参数和环境变量用来控制初始数据(16:54):

import XCTest

class LandmarksUITests: XCTestCase {

  func testLaunchWithDefaultCollection() {
    let app = XCUIApplication()
    app.launchArguments = ["ClearFavoritesOnLaunch"]
    app.launchEnvironment = ["DefaultCollectionName": "Australia 🐨 🐠"]
    app.launch()

    app.tabBars.buttons["Collections"].tap()
    XCTAssertTrue(app.buttons["Australia 🐨 🐠"].waitForExistence(timeout: 10.0))
  }
}

关键点:

  • launchArguments 在 app 内用 CommandLine.arguments 读,可以走”清空收藏”之类的测试分支。
  • launchEnvironment 在 app 内用 ProcessInfo.processInfo.environment 读,传初始值更合适。
  • 这套机制让每个测试独立可重复,不依赖上一个测试留下的状态。

四、用 URL scheme 直达页面、跑无障碍审计

URL scheme 可以跳过若干层导航,直接落地到目标页(17:04):

import XCTest

class LandmarksUITests: XCTestCase {

  func testOpenGreatBarrier() {
    let app = XCUIApplication()
    let customURL = URL(string: "landmarks://great-barrier")!
    app.open(customURL)

    XCTAssertTrue(app.wait(for: .runningForeground, timeout: 10.0))
    XCTAssertTrue(app.staticTexts["Great Barrier Reef"].waitForExistence(timeout: 10.0))
  }
}

关键点:

  • app.open(customURL) 让你的 app 自己处理 URL;测深层页面时省去层层点击。
  • 如果想模拟”在浏览器里点链接”那种系统级跳转,换成 XCUIDevice.shared.system.open(customURL)17:12)。
  • wait(for: .runningForeground, timeout:) 等 app 真正进入前台再做断言。

最后一个彩蛋:一行代码跑无障碍审计(17:13):

import XCTest

class LandmarksUITests: XCTestCase {

  func testPerformAccessibilityAudit() {
    let app = XCUIApplication()
    try app.performAccessibilityAudit()
  }
}

关键点:

  • performAccessibilityAudit() 自动扫描当前界面的对比度、命中区域、缺失 label 等问题,发现就当作测试失败。
  • 把这个测试加到 CI,能在每次 PR 上自动拦下无障碍回归。

五、Test Plan 多 locale 回放与 Test Report

Test Plan 把同一份测试代码乘以多个 configuration:每个 locale、每种设备外观、每种屏幕方向都是一组配置。Xcode Cloud 把这些 configuration 分发到云端机器并行跑,覆盖 iPhone、iPad 等设备和英、阿、希、德等语言。Test Report 把每次运行的视频聚合到一起:交互点用圆点叠加,失败时刻有 diamond 标记,点击元素可以看代码推荐,点 View Source 跳回测试源文件(03:22)。


核心启发

  • 做什么:先给关键流程的所有视图加 accessibilityIdentifier,再开始写 UI 测试。

    • 为什么值得做:identifier 是 UI 自动化的稳定主键。没有它,录制出来的代码全是本地化字符串,多语言一跑就崩。
    • 怎么开始:选 1 个核心流程(注册、下单、收藏),用 Xcode coding assistant 一条 prompt 批量加;命名约定 <Type>-<id>
  • 做什么:Test Plan 默认配三个 locale——本地语言 + 一个 RTL(阿拉伯语) + 一个长串语言(德语)。

    • 为什么值得做:RTL 验证布局镜像,长字符串验证截断和换行。这两条覆盖了 80% 的国际化 bug。
    • 怎么开始:在 .xctestplan 的 Configurations 里加三个 configuration,每个改 Localization 字段;本地先跑通,再上 Xcode Cloud。
  • 做什么:把 app.performAccessibilityAudit() 加到 CI 必跑测试。

    • 为什么值得做:一行代码就能在每个 PR 上拦截无障碍回归,比上线后用户投诉再修便宜得多。
    • 怎么开始:新建一个 UI test method,对每个核心页面跑一次审计;和现有 UI 测试共用 setup。
  • 做什么:录制完立刻把默认查询改成 identifier 或 firstMatch,删掉中间层。

    • 为什么值得做:录制生成的查询过分具体,重构 UI 后大批失效。趁热改一遍,维护成本能降一个量级。
    • 怎么开始:每行点 dropdown,优先选 identifier,没有 identifier 的回头补到源码里再录一次。
  • 做什么:把视频和截图保留策略从”仅失败”改成”全部保留”。

    • 为什么值得做:通过的运行视频可以直接做产品演示、新人 onboarding、营销素材,几乎零成本。
    • 怎么开始:在 Test Plan 的 Configurations 面板把 “UI Test Screenshots” 和 “UI Test Recording” 改为 Always。

关联 Session

评论

GitHub Issues · utterances