XCUITest Automation Patterns
Comprehensive guide to writing reliable, maintainable UI tests with XCUITest.
Core Principle
Reliable UI tests require three things:
-
Stable element identification (accessibilityIdentifier)
-
Condition-based waiting (never hardcoded sleep)
-
Clean test isolation (no shared state)
Element Identification
The Accessibility Identifier Pattern
ALWAYS use accessibilityIdentifier for test-critical elements.
// 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":
-
Localized strings change → Use accessibilityIdentifier instead
-
Deeply nested views → Use shortest possible query
-
Dynamic content → Use generic query or identifier
// 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
Waiting Strategies
Never Use sleep()
// BAD - Hardcoded wait sleep(5) XCTAssertTrue(app.buttons["submit"].exists)
// GOOD - Condition-based wait let submitButton = app.buttons["submit"] XCTAssertTrue(submitButton.waitForExistence(timeout: 5))
Wait Patterns
// 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) }
Async Operations
// 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)
}
Test Structure
Setup and Teardown
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()
}
}
Test Method Pattern
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"))
}
Common Interactions
Text Input
// 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) } }
Scrolling
// 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()
Alerts and Sheets
// 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() }
Keyboard Dismissal
// Dismiss keyboard if app.keyboards.count > 0 { app.toolbars.buttons["Done"].tap() // Or tap outside // app.tap() }
Test Plans
Multi-Configuration Testing
Test plans allow running the same tests with different configurations:
<!-- 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
xcodebuild test
-scheme "MyApp"
-testPlan "MyTestPlan"
-destination "platform=iOS Simulator,name=iPhone 16"
-resultBundlePath /tmp/results.xcresult
CI/CD Integration
Parallel Test Execution
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
xcodebuild test
-scheme "MyAppUITests"
-destination "platform=iOS Simulator,name=iPhone 16"
-retry-tests-on-failure
-test-iterations 3
-resultBundlePath /tmp/results.xcresult
Code Coverage
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
Debugging Failed Tests
Capture Screenshots
// Manual screenshot capture 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:
"systemAttachmentLifetime" : "keepAlways", "userAttachmentLifetime" : "keepAlways"
Print Element Hierarchy
// Debug: Print all elements print(app.debugDescription)
// Debug: Print specific container print(app.tables.firstMatch.debugDescription)
Anti-Patterns to Avoid
- Hardcoded Delays
// BAD sleep(5) button.tap()
// GOOD XCTAssertTrue(button.waitForExistence(timeout: 5)) button.tap()
- Index-Based Queries
// BAD - Breaks if order changes app.tables.cells.element(boundBy: 0)
// GOOD - Uses identifier app.tables.cells["firstItem"]
- Shared State Between Tests
// 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 }
- Testing Implementation Details
// BAD - Tests internal structure XCTAssertEqual(app.tables.cells.count, 10)
// GOOD - Tests user-visible behavior XCTAssertTrue(app.staticTexts["10 items"].exists)
Recording UI Automation (Xcode 26+)
From WWDC 2025-344:
-
Record — Record interactions in Xcode (Debug → Record UI Automation)
-
Replay — Run across devices/languages/configurations via test plans
-
Review — Watch video recordings in test report
Enhancing Recorded Code
// RECORDED (may be fragile) app.buttons["Login"].tap()
// ENHANCED (stable) 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