axiom-xctest-automation

XCUITest Automation Patterns

Safety Notice

This listing is imported from skills.sh public index metadata. Review upstream SKILL.md and repository scripts before running.

Copy this and send it to your AI assistant to learn

Install skill "axiom-xctest-automation" with this command: npx skills add charleswiltgen/axiom/charleswiltgen-axiom-axiom-xctest-automation

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

  1. Hardcoded Delays

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

// GOOD XCTAssertTrue(button.waitForExistence(timeout: 5)) button.tap()

  1. Index-Based Queries

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

// GOOD - Uses identifier app.tables.cells["firstItem"]

  1. 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 }

  1. 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

Source Transparency

This detail page is rendered from real SKILL.md content. Trust labels are metadata-based hints, not a safety guarantee.

Related Skills

Related by shared tags or category signals.

General

axiom-vision

No summary provided by upstream source.

Repository SourceNeeds Review
General

axiom-swiftdata

No summary provided by upstream source.

Repository SourceNeeds Review
General

axiom-swiftui-26-ref

No summary provided by upstream source.

Repository SourceNeeds Review