Skip to main content
Back to Blog
Mobile
2026-06-15

XCUITest iOS UI Testing Tutorial (2026)

XCUITest iOS UI testing tutorial for 2026: XCUIApplication, element queries, taps and typing, assertions, waitForExistence, system alerts, and CI with xcodebuild.

XCUITest iOS UI Testing Tutorial

XCUITest is Apple's first-party UI testing framework for iOS, iPadOS, tvOS, and watchOS, built on top of XCTest. You write UI tests in Swift (or Objective-C) that launch your app via XCUIApplication, locate on-screen elements through accessibility queries, drive them with taps and typing, and assert with XCTAssert. XCUITest runs in a separate process and uses the accessibility layer to interact with your app, with built-in auto-waiting on element queries — making it fast and stable without a third-party server. The core loop is: launch the app, query an element, act on it, and assert.

This tutorial covers project setup, XCUIApplication, element queries, actions, assertions, waitForExistence, recording tests, handling system alerts and keyboards, running on CI with xcodebuild, and troubleshooting. For ready-to-install QA skills for AI coding agents, browse the QASkills directory.

How XCUITest works

A UI test target is separate from your unit-test target. When it runs, XCUITest launches your app as a real process and controls it through the accessibility tree — the same data assistive technologies use. This means your elements must be reachable via accessibility identifiers, labels, or types. Because XCUITest operates on a live app out of process, you assert against real UI state, and element queries automatically wait a short time for elements to appear, which reduces flakiness compared with manual polling.

Setup

Add a UI Testing Bundle target to your Xcode project (File → New → Target → UI Testing Bundle), or check "Include Tests" when creating the project. Xcode generates a test class extending XCTestCase with setUpWithError and a sample test. A minimal skeleton:

import XCTest

final class LoginUITests: XCTestCase {
    var app: XCUIApplication!

    override func setUpWithError() throws {
        continueAfterFailure = false      // stop a test at the first failed assertion
        app = XCUIApplication()
        app.launch()
    }

    override func tearDownWithError() throws {
        app = nil
    }
}

Setting continueAfterFailure = false is standard for UI tests: once a step fails, later steps are usually meaningless, so you stop and get a clean failure point.

XCUIApplication: launching and configuring the app

XCUIApplication is your handle to the app under test. You can pass launch arguments and environment variables to put the app into a known test state — for example, a flag your app reads to skip onboarding or point at a mock backend:

let app = XCUIApplication()
app.launchArguments += ["-uiTesting", "YES"]
app.launchEnvironment["API_BASE_URL"] = "http://localhost:8080"
app.launch()

// Other lifecycle controls:
app.terminate()
app.activate()

Reading launchArguments/launchEnvironment in your app to enable deterministic test modes (seeded data, disabled animations, stubbed network) is the most important habit for reliable XCUITest suites.

Element queries: finding UI

You locate elements by type and then narrow by identifier or label. Element-type collections hang off app:

app.buttons["login"]                 // by accessibility identifier (preferred)
app.staticTexts["Welcome back"]      // by label
app.textFields["email"]
app.secureTextFields["password"]
app.switches["notifications"]
app.cells.element(boundBy: 0)        // first table/collection cell
app.navigationBars["Dashboard"]

The string subscript matches the element's accessibility identifier first, falling back to its label. Set identifiers in code or the storyboard so selectors stay stable when copy or localization changes:

// In your app:
loginButton.accessibilityIdentifier = "login"

Queries can be chained to scope into containers, and you can filter with predicates:

app.tables["feed"].cells.staticTexts["Settings"]

let containsError = NSPredicate(format: "label CONTAINS[c] %@", "error")
let errorLabel = app.staticTexts.matching(containsError).firstMatch

firstMatch short-circuits the query as soon as one element matches and is faster than evaluating the whole set — useful in hot paths.

Actions: tapping, typing, swiping

Once you have an element, act on it. Actions are synchronous and the framework waits briefly for the element to become hittable:

app.textFields["email"].tap()
app.textFields["email"].typeText("user@example.com")

app.secureTextFields["password"].tap()
app.secureTextFields["password"].typeText("hunter2")

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

app.tables["feed"].swipeUp()
app.cells.element(boundBy: 3).swipeLeft()
app.buttons["confirm"].press(forDuration: 1.0)     // long press

To dismiss the keyboard, tap another element or use the Return key:

app.keyboards.buttons["Return"].tap()

Assertions

XCUITest uses XCTest's assertions against element properties. The two most common checks are existence and label/value:

XCTAssertTrue(app.staticTexts["Welcome back"].exists)
XCTAssertEqual(app.staticTexts["cartCount"].label, "3 items")
XCTAssertFalse(app.activityIndicators["spinner"].exists)
XCTAssertTrue(app.buttons["login"].isEnabled)
XCTAssertTrue(app.switches["notifications"].isSelected)

exists returns immediately — it does not wait. For anything that appears after an asynchronous operation, do not assert exists directly; wait first (next section).

waitForExistence: handling asynchronous UI

The single most important reliability tool in XCUITest is waitForExistence(timeout:). It polls the element until it appears or the timeout elapses, returning a Bool you assert on:

let dashboard = app.staticTexts["Dashboard"]
XCTAssertTrue(dashboard.waitForExistence(timeout: 5),
              "Dashboard did not appear after login")

For more complex conditions (an element disappearing, a value changing), use XCTNSPredicateExpectation with XCTWaiter:

let spinner = app.activityIndicators["spinner"]
let gone = XCTNSPredicateExpectation(
    predicate: NSPredicate(format: "exists == false"), object: spinner)
let result = XCTWaiter().wait(for: [gone], timeout: 8)
XCTAssertEqual(result, .completed, "Spinner never disappeared")

Never use sleep(_:) to wait for UI — it is slow and still flaky. Always wait on a real condition with waitForExistence or an expectation.

A complete end-to-end test

final class CheckoutUITests: XCTestCase {
    var app: XCUIApplication!

    override func setUpWithError() throws {
        continueAfterFailure = false
        app = XCUIApplication()
        app.launchArguments += ["-uiTesting", "YES"]
        app.launch()
    }

    func testAddToCartAndCheckout() {
        // Log in
        app.textFields["email"].tap()
        app.textFields["email"].typeText("user@example.com")
        app.secureTextFields["password"].tap()
        app.secureTextFields["password"].typeText("hunter2")
        app.buttons["login"].tap()

        XCTAssertTrue(app.staticTexts["Dashboard"].waitForExistence(timeout: 5))

        // Add a product and open the cart
        app.collectionViews["catalog"].cells.element(boundBy: 0).tap()
        app.buttons["addToCart"].tap()
        XCTAssertEqual(app.staticTexts["cartCount"].label, "1")

        app.buttons["cart"].tap()
        app.buttons["checkout"].tap()

        // Confirm order
        XCTAssertTrue(app.staticTexts["Order confirmed"].waitForExistence(timeout: 5))
    }
}

Recording tests

Xcode's record UI test feature generates code as you interact with the running app — click the record button at the bottom of the editor while the cursor is inside a test method, perform actions in the simulator, and Xcode writes the corresponding queries and actions. Treat recordings as a starting point: the generated selectors often rely on labels, so replace them with stable accessibility identifiers and add explicit waitForExistence assertions before relying on them in CI.

System alerts and the keyboard

Permission dialogs (location, notifications, camera) come from the OS, not your app, so you handle them with an interruption monitor:

addUIInterruptionMonitor(withDescription: "System Dialog") { alert in
    if alert.buttons["Allow"].exists {
        alert.buttons["Allow"].tap()
        return true
    }
    return false
}
// The monitor fires on the next interaction:
app.tap()

For permission alerts you can also pre-grant via launch arguments in some flows, but the interruption monitor is the general mechanism. Springboard interactions (such as deleting the app) use a separate XCUIApplication(bundleIdentifier:) handle for the system.

Running XCUITest in CI

Run UI tests with xcodebuild test against a simulator destination on a macOS runner:

xcodebuild test \
  -scheme MyApp \
  -destination 'platform=iOS Simulator,name=iPhone 15,OS=latest' \
  -resultBundlePath TestResults.xcresult

A GitHub Actions job on macos-latest:

jobs:
  xcuitest:
    runs-on: macos-latest
    steps:
      - uses: actions/checkout@v4
      - name: Run UI tests
        run: |
          xcodebuild test \
            -scheme MyApp \
            -destination 'platform=iOS Simulator,name=iPhone 15,OS=latest' \
            -resultBundlePath TestResults.xcresult
      - name: Upload results
        if: always()
        uses: actions/upload-artifact@v4
        with: { name: xcresult, path: TestResults.xcresult }

The .xcresult bundle contains failure screenshots, logs, and per-step details — upload it as an artifact so CI-only failures are debuggable. Use Xcode test plans to manage configurations, retries, and which tests run where. More CI strategy is covered on the QASkills blog, and you can compare XCUITest against other mobile frameworks on /compare.

Structuring tests: the Page Object pattern

As a suite grows, embedding raw queries in every test becomes a maintenance burden — a single renamed identifier breaks dozens of tests. Wrap each screen in a small Page Object (often called a "screen" or "robot") that exposes intent-revealing methods and hides the queries:

struct LoginScreen {
    let app: XCUIApplication

    @discardableResult
    func login(email: String, password: String) -> LoginScreen {
        app.textFields["email"].tap()
        app.textFields["email"].typeText(email)
        app.secureTextFields["password"].tap()
        app.secureTextFields["password"].typeText(password)
        app.buttons["login"].tap()
        return self
    }

    func assertDashboardVisible(file: StaticString = #file, line: UInt = #line) {
        XCTAssertTrue(app.staticTexts["Dashboard"].waitForExistence(timeout: 5),
                      "Dashboard not visible", file: file, line: line)
    }
}

// Test reads as a sentence:
func testLogin() {
    LoginScreen(app: app)
        .login(email: "user@example.com", password: "hunter2")
        .assertDashboardVisible()
}

Passing file/line through to assertions makes failures point at the test, not the helper, which keeps reports readable.

Screenshots, attachments, and performance

Attach a screenshot to the test report at any point for visual debugging:

let shot = XCUIScreen.main.screenshot()
let attachment = XCTAttachment(screenshot: shot)
attachment.lifetime = .keepAlways
add(attachment)

XCUITest can also measure UI performance — measure(metrics:) with XCTOSSignpostMetric.applicationLaunch records app launch time across iterations, letting you guard against launch-time regressions alongside functional checks. Keep performance tests in a separate test plan so they do not slow the functional suite.

Common errors and troubleshooting

SymptomCauseFix
Failed to get matching snapshot: No matches foundElement not in accessibility tree or wrong identifierSet accessibilityIdentifier; verify the element is on screen
Assertion fails immediately after navigationUsed exists instead of waitingReplace with waitForExistence(timeout:)
Tap does nothingElement not hittable (off-screen/covered)Scroll into view; dismiss keyboard; wait until hittable
Permission dialog blocks the testOS alert not handledAdd an addUIInterruptionMonitor and trigger with app.tap()
Flaky in CI, fine locallySimulator slowness or animationsRaise timeouts; disable animations via a launch flag; use a test plan

Frequently Asked Questions

What is the difference between XCTest and XCUITest?

XCTest is Apple's overall testing framework covering unit tests, performance tests, and UI tests. XCUITest is the UI-testing portion of XCTest — the classes like XCUIApplication and XCUIElement that launch your app and drive it through the accessibility layer. In short, XCUITest is a subset of XCTest focused specifically on end-to-end UI automation.

How do I wait for an element in XCUITest?

Use waitForExistence(timeout:) on the element, which polls until it appears or the timeout elapses and returns a Bool you assert on. For more complex conditions such as an element disappearing or a value changing, build an XCTNSPredicateExpectation and wait on it with XCTWaiter. Never use sleep(_:) — it is slow and still flaky because it guesses at timing instead of waiting on real state.

Why can't XCUITest find my element?

The most common cause is that the element is not exposed in the accessibility tree or you are querying the wrong identifier. Set a stable accessibilityIdentifier on each element you interact with and query by it, since the string subscript matches the identifier first and the label second. Also confirm the element is actually on screen — off-screen elements may not be queryable until scrolled into view.

Can XCUITest handle iOS system permission alerts?

Yes. Because permission dialogs are presented by the OS rather than your app, you register an interruption monitor with addUIInterruptionMonitor(withDescription:handler:) that taps the appropriate button, then trigger it by performing any interaction such as app.tap(). The handler returns true when it handled the alert so the framework knows the interruption was resolved.

What is the best selector strategy in XCUITest?

Assign a stable accessibilityIdentifier to every element you test and query by it, because identifiers survive copy and localization changes that would break label-based selectors. Combine element-type collections (buttons, staticTexts, cells) with the identifier, and use firstMatch to speed up queries when you only need one match. Reserve predicate-based matching for cases where you must filter dynamically.

Do I need a Mac to run XCUITest?

Yes. XCUITest depends on Apple's toolchain and the iOS Simulator or real iOS devices, so you must build and run it on macOS, whether locally in Xcode or on a macOS CI runner with xcodebuild test. There is no supported way to run iOS UI tests on Linux or Windows hosts.

XCUITest iOS UI Testing Tutorial (2026) | QASkills.sh