| name | axiom-xctest-automation |
| description | Use when writing, running, or debugging XCUITests. Covers element queries, waiting strategies, accessibility identifiers, test plans, and CI/CD test execution patterns. |
| license | MIT |
| metadata | {"version":"1.0.0"} |
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.
Button("Login") { ... }
.accessibilityIdentifier("loginButton")
TextField("Email", text: $email)
.accessibilityIdentifier("emailTextField")
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
app.buttons["Login"]
app.tables.cells.element(boundBy: 0).buttons.firstMatch
app.buttons["loginButton"]
app.tables.cells.containing(.staticText, identifier: "itemTitle").firstMatch
Waiting Strategies
Never Use sleep()
sleep(5)
XCTAssertTrue(app.buttons["submit"].exists)
let submitButton = app.buttons["submit"]
XCTAssertTrue(submitButton.waitForExistence(timeout: 5))
Wait Patterns
func waitForElement(_ element: XCUIElement, timeout: TimeInterval = 10) -> Bool {
element.waitForExistence(timeout: timeout)
}
func waitForElementToDisappear(_ element: XCUIElement, timeout: TimeInterval = 10) -> Bool {
let predicate = NSPredicate(format: "exists == false")
let expectation = XCTNSPredicateExpectation(predicate: predicate, object: element)
let result = XCTWaiter.wait(for: [expectation], timeout: timeout)
return result == .completed
}
func waitForElementHittable(_ element: XCUIElement, timeout: TimeInterval = 10) -> Bool {
let predicate = NSPredicate(format: "isHittable == true")
let expectation (predicate: predicate, object: element)
result .wait(for: [expectation], timeout: timeout)
result .completed
}
( : , : ) -> {
app.staticTexts[text].waitForExistence(timeout: timeout)
}
Async Operations
func waitForNetworkResponse() {
let loadingIndicator = app.activityIndicators["loadingIndicator"]
_ = loadingIndicator.waitForExistence(timeout: 5)
_ = waitForElementToDisappear(loadingIndicator, timeout: 30)
}
Test Structure
Setup and Teardown
class LoginTests: XCTestCase {
var app: XCUIApplication!
override func setUpWithError() throws {
continueAfterFailure = false
app = XCUIApplication()
app.launchArguments = ["--uitesting", "--reset-state"]
app.launchEnvironment = ["DISABLE_ANIMATIONS": "1"]
app.launch()
}
override func tearDownWithError() throws {
if testRun?.failureCount ?? 0 > 0 {
let screenshot = XCUIScreen.main.screenshot()
let attachment = XCTAttachment(screenshot: screenshot)
attachment.name = "Failure Screenshot"
attachment.lifetime = .keepAlways
add(attachment)
}
app.terminate()
}
}
Test Method Pattern
func testLoginWithValidCredentials() throws {
let loginButton = app.buttons["showLoginButton"]
XCTAssertTrue(loginButton.waitForExistence(timeout: 5))
loginButton.tap()
let emailField = app.textFields["emailTextField"]
XCTAssertTrue(emailField.waitForExistence(timeout: 5))
emailField.tap()
emailField.typeText("user@example.com")
let passwordField = app.secureTextFields["passwordTextField"]
passwordField.tap()
passwordField.typeText("password123")
app.buttons["loginSubmitButton"].tap()
let welcomeLabel = app.staticTexts["welcomeLabel"]
XCTAssertTrue(welcomeLabel.waitForExistence(timeout: 10))
XCTAssertTrue(welcomeLabel.label.contains("Welcome"))
}
Common Interactions
Text Input
let textField = app.textFields["emailTextField"]
textField.tap()
textField.clearText()
textField.typeText("new@email.com")
extension XCUIElement {
func clearText() {
guard let stringValue = value as? String else { return }
tap()
let deleteString = String(repeating: XCUIKeyboardKey.delete.rawValue, count: stringValue.count)
typeText(deleteString)
}
}
Scrolling
func scrollToElement(_ element: XCUIElement, in scrollView: XCUIElement) {
while !element.isHittable {
scrollView.swipeUp()
}
}
let targetCell = app.tables.cells["targetItem"]
let table = app.tables.firstMatch
scrollToElement(targetCell, in: table)
targetCell.tap()
Alerts and Sheets
addUIInterruptionMonitor(withDescription: "Permission Alert") { alert in
if alert.buttons["Allow"].exists {
alert.buttons["Allow"].tap()
return true
}
return false
}
app.tap()
let alert = app.alerts["Error"]
if alert.waitForExistence(timeout: 5) {
alert.buttons["OK"].tap()
}
Keyboard Dismissal
if app.keyboards.count > 0 {
app.toolbars.buttons["Done"].tap()
}
Test Plans
Multi-Configuration Testing
Test plans allow running the same tests with different configurations:
{
"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
xcrun xcresulttool export coverage \
--path /tmp/results.xcresult \
--output-path /tmp/coverage
Debugging Failed Tests
Capture Screenshots
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
print(app.debugDescription)
print(app.tables.firstMatch.debugDescription)
Anti-Patterns to Avoid
1. Hardcoded Delays
sleep(5)
button.tap()
XCTAssertTrue(button.waitForExistence(timeout: 5))
button.tap()
2. Index-Based Queries
app.tables.cells.element(boundBy: 0)
app.tables.cells["firstItem"]
3. Shared State Between Tests
func test1_CreateItem() { ... }
func test2_EditItem() { ... }
func testCreateItem() {
}
func testEditItem() {
}
4. Testing Implementation Details
XCTAssertEqual(app.tables.cells.count, 10)
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
app.buttons["Login"].tap()
let loginButton = app.buttons["loginButton"]
XCTAssertTrue(loginButton.waitForExistence(timeout: 5))
loginButton.tap()
Resources
WWDC: 2025-344, 2024-10206, 2023-10175, 2019-413
Docs: /xctest/xcuiapplication, /xctest/xcuielement, /xctest/xcuielementquery
Skills: axiom-ui-testing, axiom-swift-testing