axiom-xctest-automation

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

XCUITest Automation Patterns

XCUITest自动化模式

Comprehensive guide to writing reliable, maintainable UI tests with XCUITest.
关于使用XCUITest编写可靠、可维护UI测试的综合指南。

Core Principle

核心原则

Reliable UI tests require three things:
  1. Stable element identification (accessibilityIdentifier)
  2. Condition-based waiting (never hardcoded sleep)
  3. Clean test isolation (no shared state)
可靠的UI测试需要满足三点:
  1. 稳定的元素识别(accessibilityIdentifier)
  2. 基于条件的等待(绝不使用硬编码休眠)
  3. 清晰的测试隔离(无共享状态)

Element Identification

元素识别

The Accessibility Identifier Pattern

可访问性标识符模式

ALWAYS use accessibilityIdentifier for test-critical elements.
swift
// SwiftUI
Button("Login") { ... }
    .accessibilityIdentifier("loginButton")

TextField("Email", text: $email)
    .accessibilityIdentifier("emailTextField")

// UIKit
loginButton.accessibilityIdentifier = "loginButton"
emailTextField.accessibilityIdentifier = "emailTextField"
对于测试关键元素,务必使用accessibilityIdentifier。
swift
// SwiftUI
Button("Login") { ... }
    .accessibilityIdentifier("loginButton")

TextField("Email", text: $email)
    .accessibilityIdentifier("emailTextField")

// UIKit
loginButton.accessibilityIdentifier = "loginButton"
emailTextField.accessibilityIdentifier = "emailTextField"

Query Selection Guidelines

查询选择指南

From WWDC 2025-344 "Recording UI Automation":
  1. Localized strings change → Use accessibilityIdentifier instead
  2. Deeply nested views → Use shortest possible query
  3. Dynamic content → Use generic query or identifier
swift
// BAD - Fragile queries
app.buttons["Login"]  // Breaks with localization
app.tables.cells.element(boundBy: 0).buttons.firstMatch  // Too specific

// GOOD - Stable queries
app.buttons["loginButton"]  // Uses identifier
app.tables.cells.containing(.staticText, identifier: "itemTitle").firstMatch
来自WWDC 2025-344《录制UI自动化》:
  1. 本地化字符串会变化 → 改用accessibilityIdentifier
  2. 深层嵌套视图 → 使用尽可能简短的查询
  3. 动态内容 → 使用通用查询或标识符
swift
// 不良示例 - 脆弱的查询
app.buttons["Login"]  // 本地化后会失效
app.tables.cells.element(boundBy: 0).buttons.firstMatch  // 过于具体

// 良好示例 - 稳定的查询
app.buttons["loginButton"]  // 使用标识符
app.tables.cells.containing(.staticText, identifier: "itemTitle").firstMatch

Waiting Strategies

等待策略

Never Use sleep()

绝不使用sleep()

swift
// BAD - Hardcoded wait
sleep(5)
XCTAssertTrue(app.buttons["submit"].exists)

// GOOD - Condition-based wait
let submitButton = app.buttons["submit"]
XCTAssertTrue(submitButton.waitForExistence(timeout: 5))
swift
// 不良示例 - 硬编码等待
sleep(5)
XCTAssertTrue(app.buttons["submit"].exists)

// 良好示例 - 基于条件的等待
let submitButton = app.buttons["submit"]
XCTAssertTrue(submitButton.waitForExistence(timeout: 5))

Wait Patterns

等待模式

swift
// Wait for element to appear
func waitForElement(_ element: XCUIElement, timeout: TimeInterval = 10) -> Bool {
    element.waitForExistence(timeout: timeout)
}

// Wait for element to disappear
func waitForElementToDisappear(_ element: XCUIElement, timeout: TimeInterval = 10) -> Bool {
    let predicate = NSPredicate(format: "exists == false")
    let expectation = XCTNSPredicateExpectation(predicate: predicate, object: element)
    let result = XCTWaiter.wait(for: [expectation], timeout: timeout)
    return result == .completed
}

// Wait for element to be hittable (visible AND enabled)
func waitForElementHittable(_ element: XCUIElement, timeout: TimeInterval = 10) -> Bool {
    let predicate = NSPredicate(format: "isHittable == true")
    let expectation = XCTNSPredicateExpectation(predicate: predicate, object: element)
    let result = XCTWaiter.wait(for: [expectation], timeout: timeout)
    return result == .completed
}

// Wait for text to appear anywhere
func waitForText(_ text: String, timeout: TimeInterval = 10) -> Bool {
    app.staticTexts[text].waitForExistence(timeout: timeout)
}
swift
// 等待元素出现
func waitForElement(_ element: XCUIElement, timeout: TimeInterval = 10) -> Bool {
    element.waitForExistence(timeout: timeout)
}

// 等待元素消失
func waitForElementToDisappear(_ element: XCUIElement, timeout: TimeInterval = 10) -> Bool {
    let predicate = NSPredicate(format: "exists == false")
    let expectation = XCTNSPredicateExpectation(predicate: predicate, object: element)
    let result = XCTWaiter.wait(for: [expectation], timeout: timeout)
    return result == .completed
}

// 等待元素可点击(可见且启用)
func waitForElementHittable(_ element: XCUIElement, timeout: TimeInterval = 10) -> Bool {
    let predicate = NSPredicate(format: "isHittable == true")
    let expectation = XCTNSPredicateExpectation(predicate: predicate, object: element)
    let result = XCTWaiter.wait(for: [expectation], timeout: timeout)
    return result == .completed
}

// 等待文本在任意位置出现
func waitForText(_ text: String, timeout: TimeInterval = 10) -> Bool {
    app.staticTexts[text].waitForExistence(timeout: timeout)
}

Async Operations

异步操作

swift
// Wait for network response
func waitForNetworkResponse() {
    let loadingIndicator = app.activityIndicators["loadingIndicator"]

    // Wait for loading to start
    _ = loadingIndicator.waitForExistence(timeout: 5)

    // Wait for loading to finish
    _ = waitForElementToDisappear(loadingIndicator, timeout: 30)
}
swift
// 等待网络响应
func waitForNetworkResponse() {
    let loadingIndicator = app.activityIndicators["loadingIndicator"]

    // 等待加载开始
    _ = loadingIndicator.waitForExistence(timeout: 5)

    // 等待加载结束
    _ = waitForElementToDisappear(loadingIndicator, timeout: 30)
}

Test Structure

测试结构

Setup and Teardown

初始化与清理

swift
class LoginTests: XCTestCase {
    var app: XCUIApplication!

    override func setUpWithError() throws {
        continueAfterFailure = false
        app = XCUIApplication()

        // Reset app state for clean test
        app.launchArguments = ["--uitesting", "--reset-state"]
        app.launchEnvironment = ["DISABLE_ANIMATIONS": "1"]
        app.launch()
    }

    override func tearDownWithError() throws {
        // Capture screenshot on failure
        if testRun?.failureCount ?? 0 > 0 {
            let screenshot = XCUIScreen.main.screenshot()
            let attachment = XCTAttachment(screenshot: screenshot)
            attachment.name = "Failure Screenshot"
            attachment.lifetime = .keepAlways
            add(attachment)
        }
        app.terminate()
    }
}
swift
class LoginTests: XCTestCase {
    var app: XCUIApplication!

    override func setUpWithError() throws {
        continueAfterFailure = false
        app = XCUIApplication()

        // 重置应用状态以保证测试干净
        app.launchArguments = ["--uitesting", "--reset-state"]
        app.launchEnvironment = ["DISABLE_ANIMATIONS": "1"]
        app.launch()
    }

    override func tearDownWithError() throws {
        // 测试失败时捕获截图
        if testRun?.failureCount ?? 0 > 0 {
            let screenshot = XCUIScreen.main.screenshot()
            let attachment = XCTAttachment(screenshot: screenshot)
            attachment.name = "Failure Screenshot"
            attachment.lifetime = .keepAlways
            add(attachment)
        }
        app.terminate()
    }
}

Test Method Pattern

测试方法模式

swift
func testLoginWithValidCredentials() throws {
    // ARRANGE - Navigate to login screen
    let loginButton = app.buttons["showLoginButton"]
    XCTAssertTrue(loginButton.waitForExistence(timeout: 5))
    loginButton.tap()

    // ACT - Enter credentials and submit
    let emailField = app.textFields["emailTextField"]
    XCTAssertTrue(emailField.waitForExistence(timeout: 5))
    emailField.tap()
    emailField.typeText("user@example.com")

    let passwordField = app.secureTextFields["passwordTextField"]
    passwordField.tap()
    passwordField.typeText("password123")

    app.buttons["loginSubmitButton"].tap()

    // ASSERT - Verify successful login
    let welcomeLabel = app.staticTexts["welcomeLabel"]
    XCTAssertTrue(welcomeLabel.waitForExistence(timeout: 10))
    XCTAssertTrue(welcomeLabel.label.contains("Welcome"))
}
swift
func testLoginWithValidCredentials() throws {
    // 准备 - 导航到登录页面
    let loginButton = app.buttons["showLoginButton"]
    XCTAssertTrue(loginButton.waitForExistence(timeout: 5))
    loginButton.tap()

    // 执行 - 输入凭证并提交
    let emailField = app.textFields["emailTextField"]
    XCTAssertTrue(emailField.waitForExistence(timeout: 5))
    emailField.tap()
    emailField.typeText("user@example.com")

    let passwordField = app.secureTextFields["passwordTextField"]
    passwordField.tap()
    passwordField.typeText("password123")

    app.buttons["loginSubmitButton"].tap()

    // 断言 - 验证登录成功
    let welcomeLabel = app.staticTexts["welcomeLabel"]
    XCTAssertTrue(welcomeLabel.waitForExistence(timeout: 10))
    XCTAssertTrue(welcomeLabel.label.contains("Welcome"))
}

Common Interactions

常见交互

Text Input

文本输入

swift
// Clear and type
let textField = app.textFields["emailTextField"]
textField.tap()
textField.clearText()  // Custom extension
textField.typeText("new@email.com")

// Extension to clear text
extension XCUIElement {
    func clearText() {
        guard let stringValue = value as? String else { return }
        tap()
        let deleteString = String(repeating: XCUIKeyboardKey.delete.rawValue, count: stringValue.count)
        typeText(deleteString)
    }
}
swift
// 清空并输入
let textField = app.textFields["emailTextField"]
textField.tap()
textField.clearText()  // 自定义扩展
textField.typeText("new@email.com")

// 用于清空文本的扩展
extension XCUIElement {
    func clearText() {
        guard let stringValue = value as? String else { return }
        tap()
        let deleteString = String(repeating: XCUIKeyboardKey.delete.rawValue, count: stringValue.count)
        typeText(deleteString)
    }
}

Scrolling

滚动

swift
// Scroll until element is visible
func scrollToElement(_ element: XCUIElement, in scrollView: XCUIElement) {
    while !element.isHittable {
        scrollView.swipeUp()
    }
}

// Scroll to specific element
let targetCell = app.tables.cells["targetItem"]
let table = app.tables.firstMatch
scrollToElement(targetCell, in: table)
targetCell.tap()
swift
// 滚动直到元素可见
func scrollToElement(_ element: XCUIElement, in scrollView: XCUIElement) {
    while !element.isHittable {
        scrollView.swipeUp()
    }
}

// 滚动到指定元素
let targetCell = app.tables.cells["targetItem"]
let table = app.tables.firstMatch
scrollToElement(targetCell, in: table)
targetCell.tap()

Alerts and Sheets

弹窗与表单

swift
// Handle system alert
addUIInterruptionMonitor(withDescription: "Permission Alert") { alert in
    if alert.buttons["Allow"].exists {
        alert.buttons["Allow"].tap()
        return true
    }
    return false
}
app.tap() // Trigger the monitor

// Handle app alert
let alert = app.alerts["Error"]
if alert.waitForExistence(timeout: 5) {
    alert.buttons["OK"].tap()
}
swift
// 处理系统弹窗
addUIInterruptionMonitor(withDescription: "Permission Alert") { alert in
    if alert.buttons["Allow"].exists {
        alert.buttons["Allow"].tap()
        return true
    }
    return false
}
app.tap() // 触发监视器

// 处理应用内弹窗
let alert = app.alerts["Error"]
if alert.waitForExistence(timeout: 5) {
    alert.buttons["OK"].tap()
}

Keyboard Dismissal

收起键盘

swift
// Dismiss keyboard
if app.keyboards.count > 0 {
    app.toolbars.buttons["Done"].tap()
    // Or tap outside
    // app.tap()
}
swift
// 收起键盘
if app.keyboards.count > 0 {
    app.toolbars.buttons["Done"].tap()
    // 或者点击外部
    // app.tap()
}

Test Plans

测试计划

Multi-Configuration Testing

多配置测试

Test plans allow running the same tests with different configurations:
xml
<!-- TestPlan.xctestplan -->
{
  "configurations" : [
    {
      "name" : "English",
      "options" : {
        "language" : "en",
        "region" : "US"
      }
    },
    {
      "name" : "Spanish",
      "options" : {
        "language" : "es",
        "region" : "ES"
      }
    },
    {
      "name" : "Dark Mode",
      "options" : {
        "userInterfaceStyle" : "dark"
      }
    }
  ],
  "testTargets" : [
    {
      "target" : {
        "containerPath" : "container:MyApp.xcodeproj",
        "identifier" : "MyAppUITests",
        "name" : "MyAppUITests"
      }
    }
  ]
}
测试计划允许使用不同配置运行相同的测试:
xml
<!-- TestPlan.xctestplan -->
{
  "configurations" : [
    {
      "name" : "English",
      "options" : {
        "language" : "en",
        "region" : "US"
      }
    },
    {
      "name" : "Spanish",
      "options" : {
        "language" : "es",
        "region" : "ES"
      }
    },
    {
      "name" : "Dark Mode",
      "options" : {
        "userInterfaceStyle" : "dark"
      }
    }
  ],
  "testTargets" : [
    {
      "target" : {
        "containerPath" : "container:MyApp.xcodeproj",
        "identifier" : "MyAppUITests",
        "name" : "MyAppUITests"
      }
    }
  ]
}

Running with Test Plan

使用测试计划运行

bash
xcodebuild test \
  -scheme "MyApp" \
  -testPlan "MyTestPlan" \
  -destination "platform=iOS Simulator,name=iPhone 16" \
  -resultBundlePath /tmp/results.xcresult
bash
xcodebuild test \
  -scheme "MyApp" \
  -testPlan "MyTestPlan" \
  -destination "platform=iOS Simulator,name=iPhone 16" \
  -resultBundlePath /tmp/results.xcresult

CI/CD Integration

CI/CD集成

Parallel Test Execution

并行测试执行

bash
xcodebuild test \
  -scheme "MyAppUITests" \
  -destination "platform=iOS Simulator,name=iPhone 16" \
  -parallel-testing-enabled YES \
  -maximum-parallel-test-targets 4 \
  -resultBundlePath /tmp/results.xcresult
bash
xcodebuild test \
  -scheme "MyAppUITests" \
  -destination "platform=iOS Simulator,name=iPhone 16" \
  -parallel-testing-enabled YES \
  -maximum-parallel-test-targets 4 \
  -resultBundlePath /tmp/results.xcresult

Retry Failed Tests

重试失败测试

bash
xcodebuild test \
  -scheme "MyAppUITests" \
  -destination "platform=iOS Simulator,name=iPhone 16" \
  -retry-tests-on-failure \
  -test-iterations 3 \
  -resultBundlePath /tmp/results.xcresult
bash
xcodebuild test \
  -scheme "MyAppUITests" \
  -destination "platform=iOS Simulator,name=iPhone 16" \
  -retry-tests-on-failure \
  -test-iterations 3 \
  -resultBundlePath /tmp/results.xcresult

Code Coverage

代码覆盖率

bash
xcodebuild test \
  -scheme "MyAppUITests" \
  -destination "platform=iOS Simulator,name=iPhone 16" \
  -enableCodeCoverage YES \
  -resultBundlePath /tmp/results.xcresult
bash
xcodebuild test \
  -scheme "MyAppUITests" \
  -destination "platform=iOS Simulator,name=iPhone 16" \
  -enableCodeCoverage YES \
  -resultBundlePath /tmp/results.xcresult

Export coverage report

导出覆盖率报告

xcrun xcresulttool export coverage
--path /tmp/results.xcresult
--output-path /tmp/coverage
undefined
xcrun xcresulttool export coverage
--path /tmp/results.xcresult
--output-path /tmp/coverage
undefined

Debugging Failed Tests

调试失败测试

Capture Screenshots

捕获截图

swift
// Manual screenshot capture
let screenshot = app.screenshot()
let attachment = XCTAttachment(screenshot: screenshot)
attachment.name = "Before Login"
attachment.lifetime = .keepAlways
add(attachment)
swift
// 手动捕获截图
let screenshot = app.screenshot()
let attachment = XCTAttachment(screenshot: screenshot)
attachment.name = "Before Login"
attachment.lifetime = .keepAlways
add(attachment)

Capture Videos

捕获视频

Enable in test plan or scheme:
xml
"systemAttachmentLifetime" : "keepAlways",
"userAttachmentLifetime" : "keepAlways"
在测试计划或scheme中启用:
xml
"systemAttachmentLifetime" : "keepAlways",
"userAttachmentLifetime" : "keepAlways"

Print Element Hierarchy

打印元素层级

swift
// Debug: Print all elements
print(app.debugDescription)

// Debug: Print specific container
print(app.tables.firstMatch.debugDescription)
swift
// 调试: 打印所有元素
print(app.debugDescription)

// 调试: 打印指定容器
print(app.tables.firstMatch.debugDescription)

Anti-Patterns to Avoid

需要避免的反模式

1. Hardcoded Delays

1. 硬编码延迟

swift
// BAD
sleep(5)
button.tap()

// GOOD
XCTAssertTrue(button.waitForExistence(timeout: 5))
button.tap()
swift
// 不良示例
sleep(5)
button.tap()

// 良好示例
XCTAssertTrue(button.waitForExistence(timeout: 5))
button.tap()

2. Index-Based Queries

2. 基于索引的查询

swift
// BAD - Breaks if order changes
app.tables.cells.element(boundBy: 0)

// GOOD - Uses identifier
app.tables.cells["firstItem"]
swift
// 不良示例 - 元素顺序变化后会失效
app.tables.cells.element(boundBy: 0)

// 良好示例 - 使用标识符
app.tables.cells["firstItem"]

3. Shared State Between Tests

3. 测试间共享状态

swift
// BAD - Tests depend on order
func test1_CreateItem() { ... }
func test2_EditItem() { ... }  // Depends on test1

// GOOD - Independent tests
func testCreateItem() {
    // Creates own item
}
func testEditItem() {
    // Creates item, then edits
}
swift
// 不良示例 - 测试依赖执行顺序
func test1_CreateItem() { ... }
func test2_EditItem() { ... }  // 依赖test1的执行结果

// 良好示例 - 独立测试
func testCreateItem() {
    // 创建独立的测试项
}
func testEditItem() {
    // 先创建测试项,再执行编辑操作
}

4. Testing Implementation Details

4. 测试实现细节

swift
// BAD - Tests internal structure
XCTAssertEqual(app.tables.cells.count, 10)

// GOOD - Tests user-visible behavior
XCTAssertTrue(app.staticTexts["10 items"].exists)
swift
// 不良示例 - 测试内部结构
XCTAssertEqual(app.tables.cells.count, 10)

// 良好示例 - 测试用户可见的行为
XCTAssertTrue(app.staticTexts["10 items"].exists)

Recording UI Automation (Xcode 26+)

录制UI自动化(Xcode 26+)

From WWDC 2025-344:
  1. Record — Record interactions in Xcode (Debug → Record UI Automation)
  2. Replay — Run across devices/languages/configurations via test plans
  3. Review — Watch video recordings in test report
来自WWDC 2025-344:
  1. 录制 — 在Xcode中录制交互操作(调试 → 录制UI自动化)
  2. 重放 — 通过测试计划在不同设备/语言/配置下运行
  3. 回顾 — 在测试报告中查看视频录制内容

Enhancing Recorded Code

优化录制的代码

swift
// RECORDED (may be fragile)
app.buttons["Login"].tap()

// ENHANCED (stable)
let loginButton = app.buttons["loginButton"]
XCTAssertTrue(loginButton.waitForExistence(timeout: 5))
loginButton.tap()
swift
// 录制生成的代码(可能脆弱)
app.buttons["Login"].tap()

// 优化后的代码(稳定)
let loginButton = app.buttons["loginButton"]
XCTAssertTrue(loginButton.waitForExistence(timeout: 5))
loginButton.tap()

Resources

参考资源

WWDC: 2025-344, 2024-10206, 2023-10175, 2019-413
Docs: /xctest/xcuiapplication, /xctest/xcuielement, /xctest/xcuielementquery
Skills: axiom-ui-testing, axiom-swift-testing
WWDC: 2025-344, 2024-10206, 2023-10175, 2019-413
文档: /xctest/xcuiapplication, /xctest/xcuielement, /xctest/xcuielementquery
技能: axiom-ui-testing, axiom-swift-testing