iOS UI Testing with XCUITest: Element Queries, Gestures, and CI

iOS UI Testing with XCUITest: Element Queries, Gestures, and CI

XCUITest is Apple's framework for automating iOS UI interactions. Tests launch the app in a simulator or on a real device, interact with it like a user would, and assert on what appears on screen. Unlike unit tests, UI tests can catch integration issues — broken navigation, missing screens, and layout bugs that only appear with real views.

Setup

Add a UI Testing Bundle target to your project (File → New → Target → UI Testing Bundle). Xcode creates a target with an XCUIApplication launch call:

import XCTest

final class AppUITests: XCTestCase {

    let app = XCUIApplication()

    override func setUpWithError() throws {
        continueAfterFailure = false
        app.launch()
    }

    override func tearDownWithError() throws {
        app.terminate()
    }
}

continueAfterFailure = false stops the test immediately after the first failure — UI tests are expensive, no point continuing after a broken state.

Finding Elements

XCUITest finds elements through queries on XCUIApplication:

// By type
let button = app.buttons["Login"]
let textField = app.textFields["Email"]
let label = app.staticTexts["Welcome"]
let cell = app.cells.firstMatch

// By index
let firstButton = app.buttons.element(boundBy: 0)

// By accessibility identifier
let submitButton = app.buttons["submitButton"]  // set via .accessibilityIdentifier in code

Setting Accessibility Identifiers

In your SwiftUI or UIKit code, set identifiers for reliable test targeting:

SwiftUI:

Button("Login") { login() }
    .accessibilityIdentifier("loginButton")

UIKit:

loginButton.accessibilityIdentifier = "loginButton"

Accessibility identifiers survive text changes and localization — more stable than querying by text.

Element Queries

The query API builds hierarchical searches:

// Direct child
let header = app.navigationBars["Dashboard"]
let doneButton = header.buttons["Done"]

// Descendant at any depth
let passwordField = app.descendants(matching: .secureTextField).firstMatch

// Predicate queries
let errorLabels = app.staticTexts.matching(NSPredicate(format: "label CONTAINS 'Error'"))

// Combine conditions
let enabledButtons = app.buttons.matching(NSPredicate(format: "isEnabled == true"))

Interactions

Tapping

app.buttons["Login"].tap()
app.cells.element(boundBy: 0).tap()
app.buttons["Login"].doubleTap()
app.buttons["Delete"].press(forDuration: 1.0)  // long press

Text Input

let emailField = app.textFields["Email"]
emailField.tap()
emailField.typeText("user@example.com")

// Clear existing text first
emailField.clearAndEnterText("new@example.com")  // custom extension

Custom extension for clearing:

extension XCUIElement {
    func clearAndEnterText(_ text: String) {
        guard let stringValue = self.value as? String, !stringValue.isEmpty else {
            tap()
            typeText(text)
            return
        }
        tap()
        let selectAll = XCUIApplication().menuItems["Select All"]
        if selectAll.waitForExistence(timeout: 1) {
            selectAll.tap()
            typeText(text)
        } else {
            let deleteString = String(repeating: XCUIKeyboardKey.delete.rawValue, count: stringValue.count)
            typeText(deleteString)
            typeText(text)
        }
    }
}

Swipe and Scroll

app.swipeUp()
app.swipeLeft()
app.tables.element.swipeDown()

// Scroll to an element
app.tables.cells["Last Item"].scrollIntoView()

Waiting for Elements

UI tests interact with async operations. Use waitForExistence instead of sleep:

let loadingSpinner = app.activityIndicators.firstMatch
XCTAssertTrue(loadingSpinner.waitForExistence(timeout: 2))

// Wait for spinner to disappear
let expectation = XCTNSPredicateExpectation(
    predicate: NSPredicate(format: "exists == false"),
    object: loadingSpinner
)
wait(for: [expectation], timeout: 10)

// Check element is visible after data loads
XCTAssertTrue(app.staticTexts["Alice"].waitForExistence(timeout: 5))

A Complete Login Test

func testSuccessfulLogin() {
    // Given
    let emailField = app.textFields["emailInput"]
    let passwordField = app.secureTextFields["passwordInput"]
    let loginButton = app.buttons["loginButton"]

    // When
    emailField.tap()
    emailField.typeText("user@example.com")

    passwordField.tap()
    passwordField.typeText("correctPassword")

    loginButton.tap()

    // Then
    XCTAssertTrue(
        app.navigationBars["Dashboard"].waitForExistence(timeout: 5),
        "Dashboard should appear after successful login"
    )
}

func testLoginWithInvalidCredentials() {
    app.textFields["emailInput"].tap()
    app.textFields["emailInput"].typeText("wrong@example.com")

    app.secureTextFields["passwordInput"].tap()
    app.secureTextFields["passwordInput"].typeText("wrongPassword")

    app.buttons["loginButton"].tap()

    XCTAssertTrue(
        app.staticTexts["Invalid credentials"].waitForExistence(timeout: 3),
        "Error message should appear for invalid credentials"
    )
}

Testing Alerts

func testLogoutConfirmation() {
    // Navigate to settings
    app.tabBars.buttons["Settings"].tap()
    app.buttons["Logout"].tap()

    // Verify alert appears
    let alert = app.alerts["Confirm Logout"]
    XCTAssertTrue(alert.waitForExistence(timeout: 2))

    // Tap confirm
    alert.buttons["Logout"].tap()

    // Verify returned to login
    XCTAssertTrue(app.textFields["emailInput"].waitForExistence(timeout: 3))
}

Screenshots in Tests

Capture screenshots on failure for debugging:

func testCheckout() {
    navigateToCart()

    let screenshot = app.screenshot()
    let attachment = XCTAttachment(screenshot: screenshot)
    attachment.lifetime = .keepAlways
    add(attachment)

    app.buttons["Checkout"].tap()
    // ...
}

In CI, screenshots are stored in the .xcresult bundle and viewable in Xcode's Report Navigator.

Launch Arguments and Environment Variables

Pass configuration to the app during UI tests:

override func setUpWithError() throws {
    continueAfterFailure = false
    app.launchArguments = ["--uitesting", "--reset-state"]
    app.launchEnvironment = ["API_URL": "https://staging.api.example.com"]
    app.launch()
}

Read in the app:

if CommandLine.arguments.contains("--uitesting") {
    // Use mock data, disable animations
    UIView.setAnimationsEnabled(false)
}

Disabling animations makes UI tests significantly faster and less flaky.

Running in CI

xcodebuild test \
  -scheme MyApp \
  -testPlan UITests \
  -destination <span class="hljs-string">'platform=iOS Simulator,name=iPhone 16,OS=18.0' \
  -resultBundlePath TestResults.xcresult \
  -parallel-testing-enabled YES \
  -maximum-parallel-testing-workers 4

Parallel testing runs test classes simultaneously on multiple simulator instances — substantially reduces total CI time.

Flakiness Prevention

UI tests are inherently more flaky than unit tests. Mitigation strategies:

  • Always use waitForExistence, never sleep
  • Disable animations via launch arguments
  • Use accessibility identifiers, not text that can change
  • Avoid pixel-perfect assertions — prefer existence checks
  • Reset app state in setUp to ensure test independence
  • Retry flaky tests in CI with --retry-tests-on-failure flag

Production Monitoring Beyond CI

XCUITest validates UI flows in simulators. For continuous monitoring of your live iOS backend APIs — the services your app depends on — HelpMeTest tests endpoints 24/7 and alerts on failures before users report them.

Summary

  • Set continueAfterFailure = false to stop tests on first failure
  • Use accessibility identifiers for stable element queries
  • waitForExistence(timeout:) instead of sleep for async operations
  • Pass launch arguments to configure the app for testing (disable animations, use mock data)
  • Parallel testing with -parallel-testing-enabled YES reduces CI time
  • Screenshots and attachments aid debugging; stored in .xcresult

Read more