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)
}
}
}
关键点:
accessibilityIdentifier是UIView自带属性,对默认就是 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适合”这里应该有一个文本,但具体内容会变”的场景。
三、加验证、设设备状态、传启动参数
录制完只是第一步,要补 XCTAssert 和 waitForExistence(15: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
- Code-along: Cook up a rich text experience in SwiftUI with AttributedString — SwiftUI 富文本编辑能力的实战 code-along。
- Embracing Swift concurrency — 复习 Swift 并发的核心概念,写测试 setup 时常用。
- Explore Swift and Java interoperability — Swift 与 Java 互操作,跨平台测试时的延伸阅读。
- Improve memory usage and performance with Swift — Swift 内存与性能优化,UI 测试发现性能回归后的补救路径。
评论
GitHub Issues · utterances