xcuitest
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseXCUITest 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 == .notRunningXCUIElement
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.ElementTypeXCUIElementQuery
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 elementsXCUICoordinate
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 tapswift
element.tap() // Single tap
element.doubleTap() // Double tap
element.twoFingerTap() // Two finger tap (iOS only)
element.tap(withNumberOfTaps: 3, numberOfTouches: 1) // Triple tapPress Actions
按压操作
swift
element.press(forDuration: 1.0) // Long press
element.press(forDuration: 0.5, thenDragTo: otherElement) // Press and dragswift
element.press(forDuration: 1.0) // Long press
element.press(forDuration: 0.5, thenDragTo: otherElement) // Press and dragText 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 keyswift
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 keySwipe 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 method uses Swift KeyPaths for type-safe property access. It returns if the property matches the expected value within the timeout, otherwise.
wait(for:toEqual:timeout:)truefalseswift
// 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"
)注意: 方法使用Swift KeyPath实现类型安全的属性访问,如果属性在超时时间内匹配到预期值则返回,否则返回。
wait(for:toEqual:timeout:)truefalsePermission 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 accessswift
// 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 accessHandling 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. methods are not main-actor-isolated by default, causing errors when accessing objects.
XCTestCase@MainActorError you'll see:
Call to main actor-isolated initializer 'init()' in a synchronous nonisolated contextSwift 6的严格并发要求正确的actor隔离。方法默认没有主actor隔离,访问对象时会报错。
XCTestCase@MainActor你会看到的错误:
Call to main actor-isolated initializer 'init()' in a synchronous nonisolated contextSolution 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 bundle in DerivedData.
Use to extract:
.xcresultxcparsebash
brew install xcparse
xcparse screenshots /path/to/Test.xcresult /output/directory截图存储在DerivedData下的包中。使用提取:
.xcresultxcparsebash
brew install xcparse
xcparse screenshots /path/to/Test.xcresult /output/directoryLaunch 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
找不到元素
- Check accessibility identifier is set in code
- Use Accessibility Inspector (Xcode > Open Developer Tool)
- Print element hierarchy:
print(app.debugDescription) - Check element is in view (may need scrolling)
- Ensure element is enabled for accessibility
- 检查代码中是否设置了无障碍ID
- 使用无障碍检查器(Xcode > 打开开发者工具)
- 打印元素层级:
print(app.debugDescription) - 检查元素是否在可视区域内(可能需要滚动)
- 确认元素已开启无障碍访问
Flaky Tests
测试不稳定
- Use instead of
waitForExistencesleep - Add
continueAfterFailure = false - Disable animations in test setup
- Reset simulator state between tests
- Use unique test data
- 使用代替
waitForExistencesleep - 设置
continueAfterFailure = false - 在测试初始化时禁用动画
- 测试之间重置模拟器状态
- 使用唯一的测试数据
Swift 6 Concurrency Errors
Swift 6 并发错误
- Mark test class with
@MainActor - Use for properties that don't need main actor
nonisolated - Use async setUp/tearDown if needed
- Check for Sendable conformance issues
- 给测试类标记
@MainActor - 不需要主Actor的属性使用标记
nonisolated - 必要时使用异步setUp/tearDown
- 检查Sendable合规性问题
Screenshots Not Saved
截图未保存
- Set
attachment.lifetime = .keepAlways - Check DerivedData location for
.xcresult - Use to extract from result bundle
xcparse
- 设置
attachment.lifetime = .keepAlways - 检查DerivedData目录下的文件
.xcresult - 使用从结果包中提取截图
xcparse
Permission Dialog Issues
权限弹窗问题
- Use before
resetAuthorizationStatus(for:)app.launch() - HealthKit uses a scrollable sheet on iOS 26 - must scroll to reveal buttons
- requires an app interaction to trigger
addUIInterruptionMonitor - For system-level alerts, use springboard bundle identifier approach
- 在之前调用
app.launch()resetAuthorizationStatus(for:) - iOS 26上HealthKit使用可滚动弹窗,需要滚动才能显示按钮
- 需要与应用交互才会触发
addUIInterruptionMonitor - 系统级弹窗使用springboard bundle ID的方式处理
What's New in Xcode 26 / iOS 26
Xcode 26 / iOS 26 新特性
New APIs
新API
- - KeyPath-based waiting for any property value
wait(for:toEqual:timeout:) - - Native API to wait for element removal (Xcode 16+)
waitForNonExistence(withTimeout:) - XCTHitchMetric - Measure UI responsiveness and frame drops
- - 基于KeyPath等待任意属性值变化
wait(for:toEqual:timeout:) - - 等待元素消失的原生API(Xcode 16+支持)
waitForNonExistence(withTimeout:) - 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官方文档
- XCTest | Apple Developer Documentation
- XCUIAutomation | Apple Developer Documentation
- XCUIElementQuery | Apple Developer Documentation
- waitForExistence | Apple Developer Documentation
- wait(for:toEqual:timeout:) | Apple Developer Documentation
- resetAuthorizationStatus(for:) | Apple Developer Documentation
- XCUIProtectedResource | Apple Developer Documentation
- XCUIScreenshot | Apple Developer Documentation
- XCTest | Apple Developer Documentation
- XCUIAutomation | Apple Developer Documentation
- XCUIElementQuery | Apple Developer Documentation
- waitForExistence | Apple Developer Documentation
- wait(for:toEqual:timeout:) | Apple Developer Documentation
- resetAuthorizationStatus(for:) | Apple Developer Documentation
- XCUIProtectedResource | Apple Developer Documentation
- XCUIScreenshot | Apple Developer Documentation
WWDC Sessions
WWDC 会议
Community Resources
社区资源
- XCTest Meets @MainActor | Quality Coding
- Issues with setUp() and tearDown() in XCTest for Swift 6 | Swift Forums
- Waiting in XCTest | Masilotti.com
- UI Testing Cheat Sheet | Masilotti.com
- XCUIElement Actions and Gestures | Apps Developer Blog
- Configuring UI tests with launch arguments | polpiella.dev
- UI Testing improvements in Xcode 16 | Jesse Squires
- XCTest Meets @MainActor | Quality Coding
- Issues with setUp() and tearDown() in XCTest for Swift 6 | Swift Forums
- Waiting in XCTest | Masilotti.com
- UI Testing Cheat Sheet | Masilotti.com
- XCUIElement Actions and Gestures | Apps Developer Blog
- Configuring UI tests with launch arguments | polpiella.dev
- UI Testing improvements in Xcode 16 | Jesse Squires
Fetching More Apple Docs
获取更多Apple文档
- Search this skill's local files first.
.md - If the topic is not here, check the other installed Apple skills you have available by their names, descriptions, or frontmatter, then grep their local files. This is faster and uses less context than fetching new docs from the internet.
SKILL.md - If no installed skill has the page, use the relevant documentation path from the local XCTest or XCUIAutomation indexes with the Markdown mirror. For example,
sosumi.aimaps to/documentation/xcuiautomation/xcuielement.https://sosumi.ai/documentation/xcuiautomation/xcuielement
- 优先搜索本技能的本地文件。
.md - 如果没有找到相关主题,可以通过名称、描述或前言检查你安装的其他Apple技能,然后检索其本地文件。这种方式比从网络获取文档更快,占用上下文更少。
SKILL.md - 如果没有已安装的技能包含对应页面,可以使用本地XCTest或XCUIAutomation索引中的相关文档路径,搭配Markdown镜像访问。例如
sosumi.ai对应地址为/documentation/xcuiautomation/xcuielement。https://sosumi.ai/documentation/xcuiautomation/xcuielement