xcuitest

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

XCUITest Reference

XCUITest 参考

Comprehensive reference for writing reliable XCUITest UI tests in Swift 6.
这是一份使用 Swift 6 编写可靠 XCUITest UI 测试的完整参考文档。

Quick Reference

快速参考

swift
// Basic test structure
@MainActor
final class MyUITests: XCTestCase {
    var app: XCUIApplication!

    override func setUp() {
        super.setUp()
        continueAfterFailure = false
        app = XCUIApplication()
        app.launch()
    }

    func testExample() {
        let button = app.buttons["Submit"]
        XCTAssertTrue(button.waitForExistence(timeout: 5))
        button.tap()
    }
}
swift
// Basic test structure
@MainActor
final class MyUITests: XCTestCase {
    var app: XCUIApplication!

    override func setUp() {
        super.setUp()
        continueAfterFailure = false
        app = XCUIApplication()
        app.launch()
    }

    func testExample() {
        let button = app.buttons["Submit"]
        XCTAssertTrue(button.waitForExistence(timeout: 5))
        button.tap()
    }
}

Core API Classes

核心API类

XCUIApplication

XCUIApplication

Proxy for launching, monitoring, and terminating the app under test.
swift
let app = XCUIApplication()

// Launch configuration
app.launchArguments = ["-UITest", "-DisableAnimations"]
app.launchEnvironment["API_URL"] = "https://test.example.com"

// Lifecycle
app.launch()      // Start the app
app.terminate()   // Stop the app
app.activate()    // Bring to foreground

// State checking
app.state == .runningForeground
app.state == .runningBackground
app.state == .notRunning
用于启动、监控和终止被测应用的代理类。
swift
let app = XCUIApplication()

// Launch configuration
app.launchArguments = ["-UITest", "-DisableAnimations"]
app.launchEnvironment["API_URL"] = "https://test.example.com"

// Lifecycle
app.launch()      // Start the app
app.terminate()   // Stop the app
app.activate()    // Bring to foreground

// State checking
app.state == .runningForeground
app.state == .runningBackground
app.state == .notRunning

XCUIElement

XCUIElement

Represents a single UI element. Supports interactions and property queries.
swift
let element = app.buttons["Submit"]

// Properties
element.exists           // Bool - element is in hierarchy
element.isHittable       // Bool - element can receive taps
element.isEnabled        // Bool - element is enabled
element.isSelected       // Bool - element is selected
element.label            // String - accessibility label
element.value            // Any? - current value
element.identifier       // String - accessibility identifier
element.frame            // CGRect - frame in screen coordinates
element.elementType      // XCUIElement.ElementType
代表单个UI元素,支持交互操作和属性查询。
swift
let element = app.buttons["Submit"]

// Properties
element.exists           // Bool - element is in hierarchy
element.isHittable       // Bool - element can receive taps
element.isEnabled        // Bool - element is enabled
element.isSelected       // Bool - element is selected
element.label            // String - accessibility label
element.value            // Any? - current value
element.identifier       // String - accessibility identifier
element.frame            // CGRect - frame in screen coordinates
element.elementType      // XCUIElement.ElementType

XCUIElementQuery

XCUIElementQuery

Defines search criteria for finding UI elements.
swift
// Type-based queries (convenience)
app.buttons              // All buttons
app.staticTexts          // All text labels
app.textFields           // All text inputs
app.secureTextFields     // Password fields
app.switches             // Toggle switches
app.sliders              // Slider controls
app.tables               // Table views
app.cells                // Table/collection cells
app.scrollViews          // Scroll views
app.images               // Image views
app.alerts               // Alert dialogs
app.sheets               // Action sheets
app.navigationBars       // Navigation bars
app.tabBars              // Tab bars
app.toolbars             // Toolbars

// Querying by identifier (subscript)
app.buttons["Submit"]
app.staticTexts["Welcome"]

// Descendants query (any element type)
app.descendants(matching: .any)
app.descendants(matching: .button)
app.descendants(matching: .staticText)

// Chained queries
app.descendants(matching: .any).matching(identifier: "my-id").firstMatch

// Predicate queries
app.buttons.matching(NSPredicate(format: "label CONTAINS[c] 'Save'"))
app.buttons.matching(NSPredicate(format: "identifier == 'submit-btn'"))
app.staticTexts.matching(NSPredicate(format: "label BEGINSWITH 'Error'"))

// Query results
query.count              // Number of matches
query.element            // Single element (fails if not exactly 1)
query.firstMatch         // First matching element
query.element(boundBy: 0) // Element at index
query.allElementsBoundByIndex  // Array of all elements
定义查找UI元素的搜索条件。
swift
// Type-based queries (convenience)
app.buttons              // All buttons
app.staticTexts          // All text labels
app.textFields           // All text inputs
app.secureTextFields     // Password fields
app.switches             // Toggle switches
app.sliders              // Slider controls
app.tables               // Table views
app.cells                // Table/collection cells
app.scrollViews          // Scroll views
app.images               // Image views
app.alerts               // Alert dialogs
app.sheets               // Action sheets
app.navigationBars       // Navigation bars
app.tabBars              // Tab bars
app.toolbars             // Toolbars

// Querying by identifier (subscript)
app.buttons["Submit"]
app.staticTexts["Welcome"]

// Descendants query (any element type)
app.descendants(matching: .any)
app.descendants(matching: .button)
app.descendants(matching: .staticText)

// Chained queries
app.descendants(matching: .any).matching(identifier: "my-id").firstMatch

// Predicate queries
app.buttons.matching(NSPredicate(format: "label CONTAINS[c] 'Save'"))
app.buttons.matching(NSPredicate(format: "identifier == 'submit-btn'"))
app.staticTexts.matching(NSPredicate(format: "label BEGINSWITH 'Error'"))

// Query results
query.count              // Number of matches
query.element            // Single element (fails if not exactly 1)
query.firstMatch         // First matching element
query.element(boundBy: 0) // Element at index
query.allElementsBoundByIndex  // Array of all elements

XCUICoordinate

XCUICoordinate

Represents a screen location for coordinate-based interactions.
swift
// Normalized offset (0,0 = top-left, 1,1 = bottom-right)
let center = element.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5))
let topLeft = element.coordinate(withNormalizedOffset: CGVector(dx: 0, dy: 0))

// Absolute offset from normalized point
let point = app.coordinate(withNormalizedOffset: .zero)
    .withOffset(CGVector(dx: 100, dy: 200))

// Screen coordinates
let screenCenter = app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5))
代表屏幕坐标点,用于基于坐标的交互操作。
swift
// Normalized offset (0,0 = top-left, 1,1 = bottom-right)
let center = element.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5))
let topLeft = element.coordinate(withNormalizedOffset: CGVector(dx: 0, dy: 0))

// Absolute offset from normalized point
let point = app.coordinate(withNormalizedOffset: .zero)
    .withOffset(CGVector(dx: 100, dy: 200))

// Screen coordinates
let screenCenter = app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5))

Element Interactions

元素交互

Tap Actions

点击操作

swift
element.tap()                    // Single tap
element.doubleTap()              // Double tap
element.twoFingerTap()           // Two finger tap (iOS only)
element.tap(withNumberOfTaps: 3, numberOfTouches: 1) // Triple tap
swift
element.tap()                    // Single tap
element.doubleTap()              // Double tap
element.twoFingerTap()           // Two finger tap (iOS only)
element.tap(withNumberOfTaps: 3, numberOfTouches: 1) // Triple tap

Press Actions

按压操作

swift
element.press(forDuration: 1.0)  // Long press
element.press(forDuration: 0.5, thenDragTo: otherElement) // Press and drag
swift
element.press(forDuration: 1.0)  // Long press
element.press(forDuration: 0.5, thenDragTo: otherElement) // Press and drag

Text Input

文本输入

swift
textField.tap()                  // Focus first
textField.typeText("Hello")      // Type text
textField.clearAndEnterText("New text") // Custom helper needed

// Clear text field
textField.tap()
textField.press(forDuration: 1.0)
app.menuItems["Select All"].tap()
textField.typeText("")           // Or use delete key
swift
textField.tap()                  // Focus first
textField.typeText("Hello")      // Type text
textField.clearAndEnterText("New text") // Custom helper needed

// Clear text field
textField.tap()
textField.press(forDuration: 1.0)
app.menuItems["Select All"].tap()
textField.typeText("")           // Or use delete key

Swipe Gestures

滑动手势

swift
element.swipeUp()
element.swipeDown()
element.swipeLeft()
element.swipeRight()

// With velocity (iOS 16+)
element.swipeUp(velocity: .fast)
element.swipeUp(velocity: .slow)
swift
element.swipeUp()
element.swipeDown()
element.swipeLeft()
element.swipeRight()

// With velocity (iOS 16+)
element.swipeUp(velocity: .fast)
element.swipeUp(velocity: .slow)

Coordinate-Based Gestures

基于坐标的手势

swift
// Pull to refresh
let start = cell.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0))
let end = cell.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 6))
start.press(forDuration: 0, thenDragTo: end)

// Custom swipe
let from = app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.8))
let to = app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.2))
from.press(forDuration: 0.1, thenDragTo: to)

// Tap at specific point
let point = app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5))
point.tap()
swift
// Pull to refresh
let start = cell.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0))
let end = cell.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 6))
start.press(forDuration: 0, thenDragTo: end)

// Custom swipe
let from = app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.8))
let to = app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.2))
from.press(forDuration: 0.1, thenDragTo: to)

// Tap at specific point
let point = app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5))
point.tap()

Other Gestures

其他手势

swift
element.pinch(withScale: 0.5, velocity: -1)  // Pinch in
element.pinch(withScale: 2.0, velocity: 1)   // Pinch out
element.rotate(0.5, withVelocity: 1)         // Rotate

// Sliders
slider.adjust(toNormalizedSliderPosition: 0.7)

// Pickers
picker.adjust(toPickerWheelValue: "Option 3")
swift
element.pinch(withScale: 0.5, velocity: -1)  // Pinch in
element.pinch(withScale: 2.0, velocity: 1)   // Pinch out
element.rotate(0.5, withVelocity: 1)         // Rotate

// Sliders
slider.adjust(toNormalizedSliderPosition: 0.7)

// Pickers
picker.adjust(toPickerWheelValue: "Option 3")

Waiting Mechanisms

等待机制

waitForExistence (Simplest)

waitForExistence(最简单用法)

swift
// Returns Bool - does not fail test automatically
let exists = element.waitForExistence(timeout: 5)
XCTAssertTrue(exists, "Element did not appear")

// Common pattern
if button.waitForExistence(timeout: 3) {
    button.tap()
}
swift
// Returns Bool - does not fail test automatically
let exists = element.waitForExistence(timeout: 5)
XCTAssertTrue(exists, "Element did not appear")

// Common pattern
if button.waitForExistence(timeout: 3) {
    button.tap()
}

XCTWaiter (More Control)

XCTWaiter(更灵活的控制)

swift
// Wait with result handling
let predicate = NSPredicate(format: "exists == true")
let expectation = XCTNSPredicateExpectation(predicate: predicate, object: element)
let result = XCTWaiter().wait(for: [expectation], timeout: 5)

switch result {
case .completed:
    // Element found
case .timedOut:
    XCTFail("Element did not appear within timeout")
case .incorrectOrder:
    // Multiple expectations fulfilled out of order
case .invertedFulfillment:
    // Inverted expectation was fulfilled (unexpected)
case .interrupted:
    // Wait was interrupted
@unknown default:
    break
}
swift
// Wait with result handling
let predicate = NSPredicate(format: "exists == true")
let expectation = XCTNSPredicateExpectation(predicate: predicate, object: element)
let result = XCTWaiter().wait(for: [expectation], timeout: 5)

switch result {
case .completed:
    // Element found
case .timedOut:
    XCTFail("Element did not appear within timeout")
case .incorrectOrder:
    // Multiple expectations fulfilled out of order
case .invertedFulfillment:
    // Inverted expectation was fulfilled (unexpected)
case .interrupted:
    // Wait was interrupted
@unknown default:
    break
}

Wait for Non-Existence (Xcode 16+)

等待元素消失(Xcode 16+)

swift
// Native API (Xcode 16+) - preferred
let loadingIndicator = app.activityIndicators["loading"]
XCTAssertTrue(loadingIndicator.waitForNonExistence(withTimeout: 10), "Loading should complete")

// Legacy approach (pre-Xcode 16)
func waitForNonExistence(_ element: XCUIElement, timeout: TimeInterval) -> 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
}
swift
// Native API (Xcode 16+) - preferred
let loadingIndicator = app.activityIndicators["loading"]
XCTAssertTrue(loadingIndicator.waitForNonExistence(withTimeout: 10), "Loading should complete")

// Legacy approach (pre-Xcode 16)
func waitForNonExistence(_ element: XCUIElement, timeout: TimeInterval) -> 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 Property Change

等待属性变化

swift
// Wait for element to become enabled
let predicate = NSPredicate(format: "isEnabled == true")
let expectation = XCTNSPredicateExpectation(predicate: predicate, object: button)
XCTWaiter().wait(for: [expectation], timeout: 5)

// Wait for label to change
let predicate = NSPredicate(format: "label == 'Done'")
let expectation = XCTNSPredicateExpectation(predicate: predicate, object: statusLabel)
XCTWaiter().wait(for: [expectation], timeout: 10)
swift
// Wait for element to become enabled
let predicate = NSPredicate(format: "isEnabled == true")
let expectation = XCTNSPredicateExpectation(predicate: predicate, object: button)
XCTWaiter().wait(for: [expectation], timeout: 5)

// Wait for label to change
let predicate = NSPredicate(format: "label == 'Done'")
let expectation = XCTNSPredicateExpectation(predicate: predicate, object: statusLabel)
XCTWaiter().wait(for: [expectation], timeout: 10)

Multiple Expectations

多期望等待

swift
let exp1 = XCTNSPredicateExpectation(predicate: pred1, object: element1)
let exp2 = XCTNSPredicateExpectation(predicate: pred2, object: element2)
XCTWaiter().wait(for: [exp1, exp2], timeout: 10, enforceOrder: false)
swift
let exp1 = XCTNSPredicateExpectation(predicate: pred1, object: element1)
let exp2 = XCTNSPredicateExpectation(predicate: pred2, object: element2)
XCTWaiter().wait(for: [exp1, exp2], timeout: 10, enforceOrder: false)

Wait for Property Value (Xcode 26+ / iOS 26+)

等待属性值(Xcode 26+ / iOS 26+)

swift
// New KeyPath-based waiting - wait for any property to equal a value
let favoriteButton = app.buttons["Favorite"]
favoriteButton.tap()

// Wait for value property to become true
XCTAssertTrue(
    favoriteButton.wait(for: \.value, toEqual: true, timeout: 10),
    "Button should show favorited state"
)

// Wait for label to change
XCTAssertTrue(
    statusLabel.wait(for: \.label, toEqual: "Complete", timeout: 5),
    "Status should update to Complete"
)

// Wait for element to become enabled
XCTAssertTrue(
    submitButton.wait(for: \.isEnabled, toEqual: true, timeout: 3),
    "Submit button should become enabled"
)
Note: The
wait(for:toEqual:timeout:)
method uses Swift KeyPaths for type-safe property access. It returns
true
if the property matches the expected value within the timeout,
false
otherwise.
swift
// New KeyPath-based waiting - wait for any property to equal a value
let favoriteButton = app.buttons["Favorite"]
favoriteButton.tap()

// Wait for value property to become true
XCTAssertTrue(
    favoriteButton.wait(for: \.value, toEqual: true, timeout: 10),
    "Button should show favorited state"
)

// Wait for label to change
XCTAssertTrue(
    statusLabel.wait(for: \.label, toEqual: "Complete", timeout: 5),
    "Status should update to Complete"
)

// Wait for element to become enabled
XCTAssertTrue(
    submitButton.wait(for: \.isEnabled, toEqual: true, timeout: 3),
    "Submit button should become enabled"
)
注意:
wait(for:toEqual:timeout:)
方法使用Swift KeyPath实现类型安全的属性访问,如果属性在超时时间内匹配到预期值则返回
true
,否则返回
false

Permission Handling

权限处理

Reset Authorization Status

重置授权状态

Reset permissions before tests to ensure consistent state. Call before
app.launch()
.
swift
override func setUp() {
    super.setUp()
    let app = XCUIApplication()

    // Reset permissions before launch
    app.resetAuthorizationStatus(for: .location)
    app.resetAuthorizationStatus(for: .camera)
    app.resetAuthorizationStatus(for: .photos)
    app.resetAuthorizationStatus(for: .health)

    app.launch()
}
在测试前重置权限以保证状态一致,需要在
app.launch()
之前调用。
swift
override func setUp() {
    super.setUp()
    let app = XCUIApplication()

    // Reset permissions before launch
    app.resetAuthorizationStatus(for: .location)
    app.resetAuthorizationStatus(for: .camera)
    app.resetAuthorizationStatus(for: .photos)
    app.resetAuthorizationStatus(for: .health)

    app.launch()
}

XCUIProtectedResource Types

XCUIProtectedResource 类型

swift
// Available protected resources
.contacts          // Contacts access
.calendar          // Calendar access
.reminders         // Reminders access
.photos            // Photo library access
.microphone        // Microphone access
.camera            // Camera access
.mediaLibrary      // Media library access
.homeKit           // HomeKit access
.bluetooth         // Bluetooth access
.keyboardNetwork   // Network keyboard access
.location          // Location services
.health            // HealthKit access
swift
// Available protected resources
.contacts          // Contacts access
.calendar          // Calendar access
.reminders         // Reminders access
.photos            // Photo library access
.microphone        // Microphone access
.camera            // Camera access
.mediaLibrary      // Media library access
.homeKit           // HomeKit access
.bluetooth         // Bluetooth access
.keyboardNetwork   // Network keyboard access
.location          // Location services
.health            // HealthKit access

Handling HealthKit Permission Dialog (iOS 26)

处理HealthKit权限弹窗(iOS 26)

HealthKit authorization on iOS 26 uses a scrollable sheet with buttons below the fold:
swift
func handleHealthKitDialog(allow: Bool = false) {
    let healthAccessText = app.staticTexts["Health Access"]

    if healthAccessText.waitForExistence(timeout: 5) {
        // Scroll down to reveal buttons (iOS 26 sheet is scrollable)
        let from = app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.8))
        let to = app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.3))
        from.press(forDuration: 0.1, thenDragTo: to)

        // Tap the appropriate button
        let buttonLabel = allow ? "Allow" : "Don't Allow"
        let button = app.buttons[buttonLabel]
        if button.waitForExistence(timeout: 5) {
            button.tap()
        } else {
            // Fallback: find by partial match
            let fallback = app.buttons.matching(
                NSPredicate(format: "label CONTAINS[c] '\(buttonLabel)'")
            ).firstMatch
            if fallback.exists { fallback.tap() }
        }
    }
}
iOS 26上的HealthKit授权使用可滚动的底部弹窗,按钮默认在可视区域外:
swift
func handleHealthKitDialog(allow: Bool = false) {
    let healthAccessText = app.staticTexts["Health Access"]

    if healthAccessText.waitForExistence(timeout: 5) {
        // Scroll down to reveal buttons (iOS 26 sheet is scrollable)
        let from = app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.8))
        let to = app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.3))
        from.press(forDuration: 0.1, thenDragTo: to)

        // Tap the appropriate button
        let buttonLabel = allow ? "Allow" : "Don't Allow"
        let button = app.buttons[buttonLabel]
        if button.waitForExistence(timeout: 5) {
            button.tap()
        } else {
            // Fallback: find by partial match
            let fallback = app.buttons.matching(
                NSPredicate(format: "label CONTAINS[c] '\(buttonLabel)'")
            ).firstMatch
            if fallback.exists { fallback.tap() }
        }
    }
}

Using UI Interruption Monitor

使用UI中断监视器

For handling unexpected permission dialogs during tests:
swift
override func setUp() {
    super.setUp()

    // Handle location permission
    addUIInterruptionMonitor(withDescription: "Location Permission") { alert -> Bool in
        if alert.buttons["Allow While Using App"].exists {
            alert.buttons["Allow While Using App"].tap()
            return true
        }
        if alert.buttons["Don't Allow"].exists {
            alert.buttons["Don't Allow"].tap()
            return true
        }
        return false
    }

    // Handle HealthKit permission
    addUIInterruptionMonitor(withDescription: "Health Permission") { alert -> Bool in
        // Note: HealthKit uses a sheet, not a system alert
        // This may not trigger the interruption monitor
        return false
    }

    app.launch()
}

func testWithPermissions() {
    // Trigger action that shows permission dialog
    app.buttons["Enable Location"].tap()

    // IMPORTANT: Must interact with app to trigger the monitor
    app.tap()

    // Continue test...
}
用于处理测试过程中意外弹出的权限对话框:
swift
override func setUp() {
    super.setUp()

    // Handle location permission
    addUIInterruptionMonitor(withDescription: "Location Permission") { alert -> Bool in
        if alert.buttons["Allow While Using App"].exists {
            alert.buttons["Allow While Using App"].tap()
            return true
        }
        if alert.buttons["Don't Allow"].exists {
            alert.buttons["Don't Allow"].tap()
            return true
        }
        return false
    }

    // Handle HealthKit permission
    addUIInterruptionMonitor(withDescription: "Health Permission") { alert -> Bool in
        // Note: HealthKit uses a sheet, not a system alert
        // This may not trigger the interruption monitor
        return false
    }

    app.launch()
}

func testWithPermissions() {
    // Trigger action that shows permission dialog
    app.buttons["Enable Location"].tap()

    // IMPORTANT: Must interact with app to trigger the monitor
    app.tap()

    // Continue test...
}

Springboard Alert Handling

Springboard 弹窗处理

For system-level alerts not caught by interruption monitor:
swift
func handleSpringboardAlert(buttonLabel: String) {
    let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard")
    let alertButton = springboard.buttons[buttonLabel]

    if alertButton.waitForExistence(timeout: 3) {
        alertButton.tap()
    }
}

// Usage
handleSpringboardAlert(buttonLabel: "Allow")
handleSpringboardAlert(buttonLabel: "Don't Allow")
用于处理中断监视器无法捕获的系统级弹窗:
swift
func handleSpringboardAlert(buttonLabel: String) {
    let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard")
    let alertButton = springboard.buttons[buttonLabel]

    if alertButton.waitForExistence(timeout: 3) {
        alertButton.tap()
    }
}

// Usage
handleSpringboardAlert(buttonLabel: "Allow")
handleSpringboardAlert(buttonLabel: "Don't Allow")

Swift 6 Concurrency

Swift 6 并发处理

The Problem

存在的问题

Swift 6 strict concurrency requires proper actor isolation.
XCTestCase
methods are not main-actor-isolated by default, causing errors when accessing
@MainActor
objects.
Error you'll see:
Call to main actor-isolated initializer 'init()' in a synchronous nonisolated context
Swift 6的严格并发要求正确的actor隔离。
XCTestCase
方法默认没有主actor隔离,访问
@MainActor
对象时会报错。
你会看到的错误:
Call to main actor-isolated initializer 'init()' in a synchronous nonisolated context

Solution 1: Mark Test Class with @MainActor (Recommended)

解决方案1:给测试类标记@MainActor(推荐)

swift
@MainActor
final class MyUITests: XCTestCase {
    var app: XCUIApplication!

    override func setUp() {
        super.setUp()
        app = XCUIApplication()
        app.launch()
    }

    override func tearDown() {
        app = nil
        super.tearDown()
    }

    func testSomething() {
        // All code runs on main actor
    }
}
swift
@MainActor
final class MyUITests: XCTestCase {
    var app: XCUIApplication!

    override func setUp() {
        super.setUp()
        app = XCUIApplication()
        app.launch()
    }

    override func tearDown() {
        app = nil
        super.tearDown()
    }

    func testSomething() {
        // All code runs on main actor
    }
}

Solution 2: Async setUp/tearDown with MainActor.run

解决方案2:使用异步setUp/tearDown配合MainActor.run

swift
final class MyUITests: XCTestCase {
    var app: XCUIApplication!

    override func setUp() async throws {
        try await super.setUp()
        await MainActor.run {
            app = XCUIApplication()
            app.launch()
        }
    }

    override func tearDown() async throws {
        await MainActor.run {
            app = nil
        }
        try await super.tearDown()
    }
}
swift
final class MyUITests: XCTestCase {
    var app: XCUIApplication!

    override func setUp() async throws {
        try await super.setUp()
        await MainActor.run {
            app = XCUIApplication()
            app.launch()
        }
    }

    override func tearDown() async throws {
        await MainActor.run {
            app = nil
        }
        try await super.tearDown()
    }
}

Solution 3: Mark Properties as nonisolated

解决方案3:将属性标记为nonisolated

For properties that don't need main actor:
swift
@MainActor
final class MyUITests: XCTestCase {
    nonisolated var testUserID: String {
        ProcessInfo.processInfo.environment["TEST_USER_ID"] ?? "default"
    }
}
适用于不需要主Actor的属性:
swift
@MainActor
final class MyUITests: XCTestCase {
    nonisolated var testUserID: String {
        ProcessInfo.processInfo.environment["TEST_USER_ID"] ?? "default"
    }
}

Async Test Methods

异步测试方法

swift
@MainActor
func testAsyncOperation() async throws {
    app.buttons["Start"].tap()

    // Await async operation
    try await Task.sleep(nanoseconds: 1_000_000_000)

    XCTAssertTrue(app.staticTexts["Complete"].exists)
}
swift
@MainActor
func testAsyncOperation() async throws {
    app.buttons["Start"].tap()

    // Await async operation
    try await Task.sleep(nanoseconds: 1_000_000_000)

    XCTAssertTrue(app.staticTexts["Complete"].exists)
}

Screenshots and Attachments

截图与附件

Take Screenshot

手动截图

swift
// Screenshot of entire screen
let screenshot = XCUIScreen.main.screenshot()

// Screenshot of specific element
let elementShot = element.screenshot()

// Create attachment
let attachment = XCTAttachment(screenshot: screenshot)
attachment.name = "Login Screen"
attachment.lifetime = .keepAlways  // Don't delete after test
add(attachment)
swift
// Screenshot of entire screen
let screenshot = XCUIScreen.main.screenshot()

// Screenshot of specific element
let elementShot = element.screenshot()

// Create attachment
let attachment = XCTAttachment(screenshot: screenshot)
attachment.name = "Login Screen"
attachment.lifetime = .keepAlways  // Don't delete after test
add(attachment)

Automatic Screenshot on Failure

失败自动截图

swift
override func tearDown() {
    if let failureCount = testRun?.failureCount, failureCount > 0 {
        let screenshot = XCUIScreen.main.screenshot()
        let attachment = XCTAttachment(screenshot: screenshot)
        attachment.name = "Failure-\(name)"
        attachment.lifetime = .keepAlways
        add(attachment)
    }
    super.tearDown()
}
swift
override func tearDown() {
    if let failureCount = testRun?.failureCount, failureCount > 0 {
        let screenshot = XCUIScreen.main.screenshot()
        let attachment = XCTAttachment(screenshot: screenshot)
        attachment.name = "Failure-\(name)"
        attachment.lifetime = .keepAlways
        add(attachment)
    }
    super.tearDown()
}

Access Screenshots After Test

测试后获取截图

Screenshots are stored in the
.xcresult
bundle in DerivedData. Use
xcparse
to extract:
bash
brew install xcparse
xcparse screenshots /path/to/Test.xcresult /output/directory
截图存储在DerivedData下的
.xcresult
包中。使用
xcparse
提取:
bash
brew install xcparse
xcparse screenshots /path/to/Test.xcresult /output/directory

Launch Arguments and Environment

启动参数与环境变量

Setting Values

设置参数

swift
let app = XCUIApplication()

// Launch arguments (appear in ProcessInfo.processInfo.arguments)
app.launchArguments = ["-UITest", "-DisableAnimations"]
app.launchArguments.append("-SkipOnboarding")

// Launch environment (appear in ProcessInfo.processInfo.environment)
app.launchEnvironment["API_URL"] = "https://test.example.com"
app.launchEnvironment["TEST_USER_ID"] = "test-user-123"

app.launch()  // Must be set before launch!
swift
let app = XCUIApplication()

// Launch arguments (appear in ProcessInfo.processInfo.arguments)
app.launchArguments = ["-UITest", "-DisableAnimations"]
app.launchArguments.append("-SkipOnboarding")

// Launch environment (appear in ProcessInfo.processInfo.environment)
app.launchEnvironment["API_URL"] = "https://test.example.com"
app.launchEnvironment["TEST_USER_ID"] = "test-user-123"

app.launch()  // Must be set before launch!

Reading in App Code

在应用代码中读取

swift
// Check for launch argument
if ProcessInfo.processInfo.arguments.contains("-UITest") {
    UIView.setAnimationsEnabled(false)
}

// Read environment variable
if let testUserID = ProcessInfo.processInfo.environment["TEST_USER_ID"] {
    UserDefaults.standard.set(testUserID, forKey: "testUserID")
}
swift
// Check for launch argument
if ProcessInfo.processInfo.arguments.contains("-UITest") {
    UIView.setAnimationsEnabled(false)
}

// Read environment variable
if let testUserID = ProcessInfo.processInfo.environment["TEST_USER_ID"] {
    UserDefaults.standard.set(testUserID, forKey: "testUserID")
}

UserDefaults Override Trick

UserDefaults 覆写技巧

Arguments with
-
prefix set UserDefaults:
swift
// In test
app.launchArguments = ["-myKey", "myValue"]

// In app - reads from UserDefaults
UserDefaults.standard.string(forKey: "myKey") // "myValue"
-
前缀的参数会自动设置到UserDefaults:
swift
// In test
app.launchArguments = ["-myKey", "myValue"]

// In app - reads from UserDefaults
UserDefaults.standard.string(forKey: "myKey") // "myValue"

Localization Testing

本地化测试

swift
app.launchArguments = [
    "-AppleLanguages", "(es)",
    "-AppleLocale", "es_ES"
]
swift
app.launchArguments = [
    "-AppleLanguages", "(es)",
    "-AppleLocale", "es_ES"
]

Accessibility Testing

无障碍测试

swift
app.launchArguments = [
    "-UIPreferredContentSizeCategoryName",
    UIContentSizeCategory.accessibilityExtraExtraExtraLarge.rawValue
]
swift
app.launchArguments = [
    "-UIPreferredContentSizeCategoryName",
    UIContentSizeCategory.accessibilityExtraExtraExtraLarge.rawValue
]

Assertions

断言

Basic Assertions

基础断言

swift
XCTAssertTrue(element.exists)
XCTAssertFalse(element.exists)
XCTAssertEqual(element.label, "Expected")
XCTAssertNotEqual(element.value as? String, "Wrong")
XCTAssertNil(element.value)
XCTAssertNotNil(element.value)
swift
XCTAssertTrue(element.exists)
XCTAssertFalse(element.exists)
XCTAssertEqual(element.label, "Expected")
XCTAssertNotEqual(element.value as? String, "Wrong")
XCTAssertNil(element.value)
XCTAssertNotNil(element.value)

With Custom Messages

自定义错误消息

swift
XCTAssertTrue(element.exists, "Submit button should be visible")
XCTAssertEqual(element.label, "Done", "Button label should be 'Done' after completion")
swift
XCTAssertTrue(element.exists, "Submit button should be visible")
XCTAssertEqual(element.label, "Done", "Button label should be 'Done' after completion")

Existence Patterns

存在性判断模式

swift
// Element must exist (fails test if not)
XCTAssertTrue(element.waitForExistence(timeout: 5), "Element not found")

// Element should not exist
XCTAssertFalse(app.alerts["Error"].exists, "Unexpected error alert")

// Element state
XCTAssertTrue(button.isEnabled, "Button should be enabled")
XCTAssertTrue(button.isHittable, "Button should be tappable")
swift
// Element must exist (fails test if not)
XCTAssertTrue(element.waitForExistence(timeout: 5), "Element not found")

// Element should not exist
XCTAssertFalse(app.alerts["Error"].exists, "Unexpected error alert")

// Element state
XCTAssertTrue(button.isEnabled, "Button should be enabled")
XCTAssertTrue(button.isHittable, "Button should be tappable")

System Alerts

系统弹窗

Interruption Monitor (for system dialogs)

中断监视器(针对系统对话框)

swift
override func setUp() {
    super.setUp()

    // Set up before launching app
    addUIInterruptionMonitor(withDescription: "System Alert") { alert -> Bool in
        // Handle location permission
        if alert.buttons["Allow While Using App"].exists {
            alert.buttons["Allow While Using App"].tap()
            return true
        }
        // Handle notification permission
        if alert.buttons["Allow"].exists {
            alert.buttons["Allow"].tap()
            return true
        }
        // Handle "Don't Allow"
        if alert.buttons["Don't Allow"].exists {
            alert.buttons["Don't Allow"].tap()
            return true
        }
        return false
    }

    app.launch()
}

func testWithSystemAlert() {
    // Trigger action that shows system alert
    app.buttons["Request Permission"].tap()

    // IMPORTANT: Must interact with app to trigger handler
    app.tap()

    // Continue test...
}
swift
override func setUp() {
    super.setUp()

    // Set up before launching app
    addUIInterruptionMonitor(withDescription: "System Alert") { alert -> Bool in
        // Handle location permission
        if alert.buttons["Allow While Using App"].exists {
            alert.buttons["Allow While Using App"].tap()
            return true
        }
        // Handle notification permission
        if alert.buttons["Allow"].exists {
            alert.buttons["Allow"].tap()
            return true
        }
        // Handle "Don't Allow"
        if alert.buttons["Don't Allow"].exists {
            alert.buttons["Don't Allow"].tap()
            return true
        }
        return false
    }

    app.launch()
}

func testWithSystemAlert() {
    // Trigger action that shows system alert
    app.buttons["Request Permission"].tap()

    // IMPORTANT: Must interact with app to trigger handler
    app.tap()

    // Continue test...
}

Direct Alert Handling

直接处理弹窗

swift
// For app alerts (not system)
let alert = app.alerts["Confirm Delete"]
if alert.waitForExistence(timeout: 3) {
    alert.buttons["Delete"].tap()
}

// For springboard alerts (system)
let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard")
let systemAlert = springboard.alerts.firstMatch
if systemAlert.waitForExistence(timeout: 3) {
    systemAlert.buttons["Allow"].tap()
}
swift
// For app alerts (not system)
let alert = app.alerts["Confirm Delete"]
if alert.waitForExistence(timeout: 3) {
    alert.buttons["Delete"].tap()
}

// For springboard alerts (system)
let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard")
let systemAlert = springboard.alerts.firstMatch
if systemAlert.waitForExistence(timeout: 3) {
    systemAlert.buttons["Allow"].tap()
}

Common Patterns

常用模式

Page Object Model

页面对象模型

swift
protocol Screen {
    var app: XCUIApplication { get }
    func waitForScreen(timeout: TimeInterval) -> Bool
}

struct LoginScreen: Screen {
    let app: XCUIApplication

    var usernameField: XCUIElement { app.textFields["username"] }
    var passwordField: XCUIElement { app.secureTextFields["password"] }
    var loginButton: XCUIElement { app.buttons["Login"] }
    var errorLabel: XCUIElement { app.staticTexts["error-message"] }

    func waitForScreen(timeout: TimeInterval = 5) -> Bool {
        usernameField.waitForExistence(timeout: timeout)
    }

    func login(username: String, password: String) -> HomeScreen {
        usernameField.tap()
        usernameField.typeText(username)
        passwordField.tap()
        passwordField.typeText(password)
        loginButton.tap()
        return HomeScreen(app: app)
    }
}
swift
protocol Screen {
    var app: XCUIApplication { get }
    func waitForScreen(timeout: TimeInterval) -> Bool
}

struct LoginScreen: Screen {
    let app: XCUIApplication

    var usernameField: XCUIElement { app.textFields["username"] }
    var passwordField: XCUIElement { app.secureTextFields["password"] }
    var loginButton: XCUIElement { app.buttons["Login"] }
    var errorLabel: XCUIElement { app.staticTexts["error-message"] }

    func waitForScreen(timeout: TimeInterval = 5) -> Bool {
        usernameField.waitForExistence(timeout: timeout)
    }

    func login(username: String, password: String) -> HomeScreen {
        usernameField.tap()
        usernameField.typeText(username)
        passwordField.tap()
        passwordField.typeText(password)
        loginButton.tap()
        return HomeScreen(app: app)
    }
}

Reusable Helpers

可复用助手方法

swift
extension XCUIApplication {
    func waitForElement(_ identifier: String, timeout: TimeInterval = 5) -> XCUIElement {
        let element = descendants(matching: .any).matching(identifier: identifier).firstMatch
        XCTAssertTrue(element.waitForExistence(timeout: timeout),
                      "Element '\(identifier)' not found within \(timeout)s")
        return element
    }

    func tapTab(_ identifier: String) {
        let tab = buttons[identifier]
        XCTAssertTrue(tab.waitForExistence(timeout: 5), "Tab '\(identifier)' not found")
        tab.tap()
    }
}

extension XCUIElement {
    func clearAndType(_ text: String) {
        guard let currentValue = value as? String, !currentValue.isEmpty else {
            tap()
            typeText(text)
            return
        }
        tap()
        let deleteString = String(repeating: XCUIKeyboardKey.delete.rawValue, count: currentValue.count)
        typeText(deleteString)
        typeText(text)
    }
}
swift
extension XCUIApplication {
    func waitForElement(_ identifier: String, timeout: TimeInterval = 5) -> XCUIElement {
        let element = descendants(matching: .any).matching(identifier: identifier).firstMatch
        XCTAssertTrue(element.waitForExistence(timeout: timeout),
                      "Element '\(identifier)' not found within \(timeout)s")
        return element
    }

    func tapTab(_ identifier: String) {
        let tab = buttons[identifier]
        XCTAssertTrue(tab.waitForExistence(timeout: 5), "Tab '\(identifier)' not found")
        tab.tap()
    }
}

extension XCUIElement {
    func clearAndType(_ text: String) {
        guard let currentValue = value as? String, !currentValue.isEmpty else {
            tap()
            typeText(text)
            return
        }
        tap()
        let deleteString = String(repeating: XCUIKeyboardKey.delete.rawValue, count: currentValue.count)
        typeText(deleteString)
        typeText(text)
    }
}

Timeout Constants

超时常量

swift
enum TestTimeout: TimeInterval {
    case short = 2
    case medium = 5
    case long = 10
    case networkLoad = 30
}

// Usage
button.waitForExistence(timeout: TestTimeout.medium.rawValue)
swift
enum TestTimeout: TimeInterval {
    case short = 2
    case medium = 5
    case long = 10
    case networkLoad = 30
}

// Usage
button.waitForExistence(timeout: TestTimeout.medium.rawValue)

Advanced Patterns

高级模式

For more detailed patterns including:
  • Base test class implementation
  • iOS system dialog handling (HealthKit, Location, Notifications)
  • Scroll and wait patterns
  • Text field helpers
  • Debugging techniques
  • Test organization
See patterns.md.
如需了解更详细的模式,包括:
  • 基础测试类实现
  • iOS系统弹窗处理(HealthKit、位置、通知)
  • 滚动与等待模式
  • 文本输入助手
  • 调试技巧
  • 测试组织
请查看 patterns.md

Troubleshooting

问题排查

Element Not Found

找不到元素

  1. Check accessibility identifier is set in code
  2. Use Accessibility Inspector (Xcode > Open Developer Tool)
  3. Print element hierarchy:
    print(app.debugDescription)
  4. Check element is in view (may need scrolling)
  5. Ensure element is enabled for accessibility
  1. 检查代码中是否设置了无障碍ID
  2. 使用无障碍检查器(Xcode > 打开开发者工具)
  3. 打印元素层级:
    print(app.debugDescription)
  4. 检查元素是否在可视区域内(可能需要滚动)
  5. 确认元素已开启无障碍访问

Flaky Tests

测试不稳定

  1. Use
    waitForExistence
    instead of
    sleep
  2. Add
    continueAfterFailure = false
  3. Disable animations in test setup
  4. Reset simulator state between tests
  5. Use unique test data
  1. 使用
    waitForExistence
    代替
    sleep
  2. 设置
    continueAfterFailure = false
  3. 在测试初始化时禁用动画
  4. 测试之间重置模拟器状态
  5. 使用唯一的测试数据

Swift 6 Concurrency Errors

Swift 6 并发错误

  1. Mark test class with
    @MainActor
  2. Use
    nonisolated
    for properties that don't need main actor
  3. Use async setUp/tearDown if needed
  4. Check for Sendable conformance issues
  1. 给测试类标记
    @MainActor
  2. 不需要主Actor的属性使用
    nonisolated
    标记
  3. 必要时使用异步setUp/tearDown
  4. 检查Sendable合规性问题

Screenshots Not Saved

截图未保存

  1. Set
    attachment.lifetime = .keepAlways
  2. Check DerivedData location for
    .xcresult
  3. Use
    xcparse
    to extract from result bundle
  1. 设置
    attachment.lifetime = .keepAlways
  2. 检查DerivedData目录下的
    .xcresult
    文件
  3. 使用
    xcparse
    从结果包中提取截图

Permission Dialog Issues

权限弹窗问题

  1. Use
    resetAuthorizationStatus(for:)
    before
    app.launch()
  2. HealthKit uses a scrollable sheet on iOS 26 - must scroll to reveal buttons
  3. addUIInterruptionMonitor
    requires an app interaction to trigger
  4. For system-level alerts, use springboard bundle identifier approach
  1. app.launch()
    之前调用
    resetAuthorizationStatus(for:)
  2. iOS 26上HealthKit使用可滚动弹窗,需要滚动才能显示按钮
  3. addUIInterruptionMonitor
    需要与应用交互才会触发
  4. 系统级弹窗使用springboard bundle ID的方式处理

What's New in Xcode 26 / iOS 26

Xcode 26 / iOS 26 新特性

New APIs

新API

  • wait(for:toEqual:timeout:)
    - KeyPath-based waiting for any property value
  • waitForNonExistence(withTimeout:)
    - Native API to wait for element removal (Xcode 16+)
  • XCTHitchMetric - Measure UI responsiveness and frame drops
  • wait(for:toEqual:timeout:)
    - 基于KeyPath等待任意属性值变化
  • waitForNonExistence(withTimeout:)
    - 等待元素消失的原生API(Xcode 16+支持)
  • XCTHitchMetric - 测量UI响应速度和帧丢失情况

Enhanced Recording

增强的录制功能

  • Cleaner, more maintainable generated test code
  • Multiple identifier options for element selection
  • Integration with Test Report's Automation Explorer
  • 生成更简洁、易维护的测试代码
  • 支持多种元素ID选择方式
  • 与测试报告的自动化资源管理器集成

Cross-Platform Testing

跨平台测试

  • Run automation tests across iPhone, iPad, Mac, Apple TV, Apple Watch
  • Test plan configurations for multiple locales, device types, system conditions
  • Video recordings and screenshots of test runs in results
  • 可在iPhone、iPad、Mac、Apple TV、Apple Watch上运行自动化测试
  • 支持多语言、多设备类型、多系统条件的测试计划配置
  • 测试结果中包含运行过程的视频录像和截图

Swift Concurrency Debugging

Swift 并发调试

  • Seamless stepping across threads in async tests
  • Task ID visibility in debugger
  • Thread Performance Checker integration
  • 支持在异步测试中跨线程无缝单步调试
  • 调试器中可查看Task ID
  • 集成线程性能检查器

Sources

参考来源

Apple Documentation

Apple官方文档

WWDC Sessions

WWDC 会议

Community Resources

社区资源

Fetching More Apple Docs

获取更多Apple文档

  1. Search this skill's local
    .md
    files first.
  2. If the topic is not here, check the other installed Apple skills you have available by their names, descriptions, or
    SKILL.md
    frontmatter, then grep their local files. This is faster and uses less context than fetching new docs from the internet.
  3. If no installed skill has the page, use the relevant documentation path from the local XCTest or XCUIAutomation indexes with the
    sosumi.ai
    Markdown mirror. For example,
    /documentation/xcuiautomation/xcuielement
    maps to
    https://sosumi.ai/documentation/xcuiautomation/xcuielement
    .
  1. 优先搜索本技能的本地
    .md
    文件。
  2. 如果没有找到相关主题,可以通过名称、描述或
    SKILL.md
    前言检查你安装的其他Apple技能,然后检索其本地文件。这种方式比从网络获取文档更快,占用上下文更少。
  3. 如果没有已安装的技能包含对应页面,可以使用本地XCTest或XCUIAutomation索引中的相关文档路径,搭配
    sosumi.ai
    Markdown镜像访问。例如
    /documentation/xcuiautomation/xcuielement
    对应地址为
    https://sosumi.ai/documentation/xcuiautomation/xcuielement