1. 从基础到进阶:重新认识 XCTest 与 UITests
如果你已经用 XCTest 写过几个简单的测试用例,比如点击按钮、验证文本,可能会觉得自动化测试不过如此。但当你真正想把测试用在复杂的项目里,比如一个满是动态列表的电商App,或者一个需要处理各种系统弹窗的社交应用时,就会发现之前学的那些“三板斧”有点不够用了。我刚开始做自动化测试时也踩过不少坑,比如测试跑着跑着就卡住了,或者明明元素在那里,代码就是找不到它,急得人直挠头。
其实,XCTest 框架,特别是 UITests,它的能力远比我们想象的要强大。它不仅仅能模拟点击,还能处理表格的无限滚动、长按拖拽、双指缩放,甚至能帮你自动点击系统弹出的权限申请框,或者跳转到 Safari 浏览器去验证一个网页链接。这些“骚操作”才是提升我们测试效率、保证应用质量的关键。这篇文章,我就把自己这些年积累的实战经验和进阶技巧分享给你,咱们不聊枯燥的理论,直接上干货,说说怎么让自动化测试真正“动”起来,帮你省下大量重复劳动的时间。
2. 元素定位:从“能找到”到“找得准、找得快”
写 UI 测试,十有八九的时间都花在定位元素上。定位不准,后面的所有操作都是白搭。除了最基础的通过label或accessibilityIdentifier来查找,我们得掌握更多“稳准狠”的招式。
2.1 活用debugDescription:看清界面“全家福”
当你对界面结构不熟悉,或者不确定元素的具体属性时,app.debugDescription是你的最佳伙伴。这行代码会打印出当前应用界面的完整视图层级树,就像给界面拍了一张 X 光片。
let app = XCUIApplication() app.launch() print(app.debugDescription)打印出来的信息非常详细,包含了每个视图的类型(Button、StaticText、Table)、内存地址、位置尺寸,以及最重要的label和identifier。我习惯在写复杂测试前先跑一遍这个,把输出的内容存下来慢慢研究。这样你就知道,哦,原来这个按钮是嵌套在第三个Other容器里的第二个Button,它的identifier是“submit_button”。有了这张“地图”,你再写定位代码就心里有底了。
2.2 组合查询与链式调用:精准狙击目标元素
很多时候,光靠一个条件定位元素是不够的,容易找到多个匹配项。这时候就需要组合查询。XCTest 的查询 API 非常灵活,支持链式调用。
比如,你要定位一个特定表格(identifier为“feed_list”)里,第一个分区(section)的第五行(cell)。你可以这样写:
let targetCell = app.tables[“feed_list”].cells.element(boundBy: 4)又比如,你想找到一个视图里,同时满足是按钮并且标题是“确认”的元素:
let confirmButton = app.buttons.matching(identifier: “confirm_button”).firstMatch // 或者结合 NSPredicate 进行更复杂的查询 let predicate = NSPredicate(format: “label CONTAINS ‘购买’ AND enabled == true”) let buyButton = app.buttons.containing(predicate).firstMatch链式调用能让你的意图非常清晰。记住一个原则:查询路径越具体,定位就越快、越准。尽量避免使用app.buttons[“确认”]这种全局查找,除非你非常确定这个label在全应用是唯一的。
2.3 等待的艺术:处理动态加载与网络请求
这是新手最容易栽跟头的地方。你的测试代码执行得飞快,但 App 的界面可能还在加载数据、渲染视图。直接去查找一个不存在的元素,测试就会失败。
强制等待(sleep)是最差的选择,它会让测试变得缓慢且不稳定。我们应该使用条件等待。
XCTest 提供了waitForExistence(timeout:)方法,它会一直等待,直到元素出现在视图层级中,或者超时。
let loginButton = app.buttons[“用户登录”] // 等待最多5秒,直到登录按钮出现 XCTAssertTrue(loginButton.waitForExistence(timeout: 5), “登录按钮没有出现”) loginButton.tap()对于更复杂的条件,比如等待一个加载指示器消失,可以使用expectation和wait:
let loadingIndicator = app.activityIndicators[“正在加载”] let disappearExpectation = expectation(for: NSPredicate(format: “exists == false”), evaluatedWith: loadingIndicator, handler: nil) wait(for: [disappearExpectation], timeout: 10.0) // 加载指示器消失后,再继续后续操作在实际项目中,我通常会把这些等待逻辑封装成辅助方法,比如waitForElementToAppear(_: timeout:)和waitForElementToDisappear(_: timeout:),让测试代码更清爽。
3. 驾驭复杂交互:让测试像真人一样操作
真实的用户操作不只是简单的点击。他们会在列表里上下滑动寻找商品,会长按某个项目弹出菜单,会在相册里用双指放大图片。我们的测试也需要模拟这些。
3.1 表格(Table View)的滚动与操作
测试带有大量数据的列表是常见需求。核心思路是:先让目标单元格滚动到屏幕内(变得可点击isHittable),然后再操作它。
这里分享一个我常用的滚动查找单元格的扩展方法,它比单纯地连续swipeUp()更可靠:
extension XCUIElement { /// 在表格(或集合视图)中滚动查找指定元素 func scrollToElement(_ element: XCUIElement, maxSwipes: Int = 20) -> Bool { guard self.elementType == .table || self.elementType == .collectionView else { return false } var swipeCount = 0 while !element.isHittable && swipeCount < maxSwipes { swipeUp() swipeCount += 1 // 每次滑动后稍作停顿,让视图稳定 Thread.sleep(forTimeInterval: 0.5) } return element.isHittable } } // 使用示例 func testSelectItemInLongList() { let app = XCUIApplication() let tableView = app.tables[“product_list”] // 假设我们要找第50个单元格 let targetCell = tableView.cells.element(boundBy: 49) if tableView.scrollToElement(targetCell) { targetCell.tap() // 验证是否进入了详情页 XCTAssertTrue(app.navigationBars[“商品详情”].exists) } else { XCTFail(“未能找到目标单元格”) } }3.2 处理系统弹窗(Alert)与权限申请
这是自动化测试必须跨过的坎。当你的测试触发相机、相册、通知等权限申请时,一个系统弹窗会盖在你的 App 之上。你的测试代码必须能识别并操作这个弹窗。
关键点在于:系统弹窗属于一个独立的 App,它的bundleIdentifier是“com.apple.springboard”。
你不能在你的主 App 的上下文中去查找这个弹窗的按钮。正确做法是:
func testPhotoLibraryPermission() { let app = XCUIApplication() app.launch() // 点击触发相册权限申请的按钮 app.buttons[“选择照片”].tap() // 此时系统弹窗出现,切换到 Springboard 上下文 let springboard = XCUIApplication(bundleIdentifier: “com.apple.springboard”) // 等待弹窗出现 let alert = springboard.alerts.firstMatch XCTAssertTrue(alert.waitForExistence(timeout: 2)) // 点击“允许”按钮(通常是第二个按钮,下标为1) alert.buttons[“允许”].tap() // 或者用 alerts.buttons.element(boundBy: 1).tap() // 弹窗消失,切回主 App 上下文继续测试 XCTAssertTrue(app.images[“selected_photo”].waitForExistence(timeout: 3)) }我建议把处理系统弹窗的逻辑(比如总是点击“允许”或“不允许”)封装成一个公共方法,这样所有测试用例都能调用,保持策略一致。
3.3 多点触控与复杂手势
对于一些交互性强的界面,可能需要模拟复杂手势。UITests 对此有很好的支持。
let imageView = app.images[“preview_image”] // 双指放大 imageView.pinch(withScale: 2.0, velocity: 1.0) // velocity 是缩放速度,一般设为1 // 双指旋转 imageView.rotate(1.0, withVelocity: 1.0) // 旋转弧度 // 长按 imageView.press(forDuration: 2.0) // 拖拽 (从一个元素拖到另一个元素) let elementA = app.buttons[“item_a”] let elementB = app.buttons[“item_b”] elementA.press(forDuration: 0.5, thenDragTo: elementB)使用这些手势时要注意,目标元素必须已经在屏幕上并且是isHittable的。有时候需要先滚动或导航到正确的界面。
4. 超越单个应用:跨应用测试与系统集成
真正的用户体验流可能跨越多个应用。比如,从你的 App 分享内容到微信,或者点击一个链接跳转到 Safari。我们的测试也应该能覆盖这些场景。
4.1 启动与操作其他 App
通过指定bundleIdentifier,你可以初始化并操作任何已安装的 App,包括系统应用。
// 启动 Safari let safariApp = XCUIApplication(bundleIdentifier: “com.apple.mobilesafari”) safariApp.launch() // 在 Safari 的地址栏输入网址 let addressBar = safariApp.textFields[“地址栏”] if addressBar.waitForExistence(timeout: 3) { addressBar.tap() addressBar.typeText(“https://www.example.com\n”) // 输入后按回车 } // 操作完成后,可以按 Home 键回到桌面,或者再启动自己的 App XCUIDevice.shared.press(.home) let myApp = XCUIApplication() // 这会重新激活你自己的 App4.2 与系统桌面(Springboard)交互
Springboard就是 iOS 的桌面。除了用它来处理系统弹窗,你还可以做更多事情,比如检查设备状态,或者操作桌面上的图标(但这通常不太稳定,且受系统版本影响大)。
let springboard = XCUIApplication(bundleIdentifier: “com.apple.springboard”) // 注意:springboard.launch() 通常不需要调用,它一直在运行 // 你可以尝试获取桌面图标的数量(这只是一个探索性示例,实际可能因布局而异) let icons = springboard.icons print(“桌面图标数量大约为:\(icons.count)”)跨应用测试的稳定性挑战更大,因为涉及到进程间切换和不同 App 的加载时间。务必增加足够的等待逻辑,并且这类测试最好在专用的、干净的模拟器上运行。
4.3 模拟物理按键
UITests 可以模拟按下物理按键,这在测试一些依赖硬件按键的功能时非常有用。
// 按下 Home 键 XCUIDevice.shared.press(.home) // 按下音量加键 XCUIDevice.shared.press(.volumeUp) // 按下音量减键 XCUIDevice.shared.press(.volumeDown) // 锁定屏幕(注意:这可能会中断测试!慎用) // XCUIDevice.shared.press(.power)我曾经用这个特性来测试一个音频播放 App,模拟用户在锁屏状态下用音量键控制播放,非常方便。
5. 构建健壮且可维护的测试套件
当测试用例越来越多,如何组织代码就变得至关重要。好的架构能让测试易于编写、阅读和维护。
5.1 使用 Page Object 模式
这是 UI 自动化测试中最经典、最有效的设计模式。它的核心思想是为每个屏幕(页面)创建一个对象类,将这个页面的所有元素定位和基本操作封装在里面。测试用例则使用这些页面对象来完成流程,而不直接与底层元素交互。
举个例子,我们有一个登录页:
class LoginPage { private let app: XCUIApplication // 元素 var usernameField: XCUIElement { app.textFields[“username”] } var passwordField: XCUIElement { app.secureTextFields[“password”] } var loginButton: XCUIElement { app.buttons[“登录”] } var errorMessage: XCUIElement { app.staticTexts[“error_label”] } init(app: XCUIApplication) { self.app = app } // 操作 func login(username: String, password: String) { usernameField.tap() usernameField.typeText(username) passwordField.tap() passwordField.typeText(password) loginButton.tap() } func isErrorMessageDisplayed() -> Bool { return errorMessage.waitForExistence(timeout: 2) } } // 在测试用例中的使用变得非常清晰 func testLoginFailure() { let app = XCUIApplication() let loginPage = LoginPage(app: app) loginPage.login(username: “wrong”, password: “wrong”) XCTAssertTrue(loginPage.isErrorMessageDisplayed()) }这样做的好处是:如果登录页的 UI 改了,比如username字段的identifier变了,你只需要修改LoginPage类中的一处代码,所有相关的测试用例就都修复了。
5.2 测试数据的准备与清理
测试不应该依赖 App 的现有状态。理想情况下,每次测试都应该从一个干净、已知的状态开始。对于需要登录的测试,我通常会在setUp()方法里通过 API 或测试账号直接登录,而不是走 UI 流程。对于会产生数据的测试(如发布内容),一定要在tearDown()方法里清理掉,避免影响后续测试。
你可以利用XCUIApplication的launchArguments和launchEnvironment向 App 传递参数,告诉它当前是测试模式,从而触发一些特殊的初始化逻辑,比如使用模拟数据、跳过引导页等。
// 在测试类中 override func setUp() { super.setUp() let app = XCUIApplication() app.launchArguments = [“-UITesting”, “-StartFromCleanState”] app.launch() } // 在你的 AppDelegate 或 SceneDelegate 中 func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { if ProcessInfo.processInfo.arguments.contains(“-UITesting”) { // 执行测试环境特有的设置,例如使用内存数据库、注入模拟用户 setupTestEnvironment() } return true }5.3 并行测试与性能考量
随着测试套件膨胀,执行时间会越来越长。Xcode 支持在多个模拟器上并行运行测试。你需要做的是确保测试之间是独立的,没有共享状态。使用我上面提到的数据准备与清理方法,是达成这一目标的关键。
另外,避免在测试中使用sleep,多用条件等待。一个等待 10 秒的测试,如果改用waitForExistence,可能在元素出现后(比如 1 秒)就立刻继续了,能节省大量时间。
最后,记得给你的测试用例起一个有意义的名字,比如testUserCanCheckoutWithCreditCard,而不是testCase1。这样当测试失败时,你一眼就能知道是哪个功能出了问题。写测试代码和写产品代码一样,需要用心设计。当你发现新增一个功能后,能通过运行一遍测试就快速验证核心流程没被破坏时,你会觉得之前投入在搭建测试框架上的时间都是值得的。