xcuitest-suite

Use whenever an iOS, iPadOS, tvOS or macOS app needs UI tests in Apple's XCUITest: XCUIApplication, XCUIElement and queries, accessibility identifiers in app code, xcodebuild test with a destination, and reading the xcresult bundle, plus macOS specifics such as privacy permission resets.

/plugin install qa-mobile@testland-qa

First time in Claude Code? Add the marketplace with /plugin marketplace add testland/qa

View source (opens in a new tab)

Overview

XCUITest is Apple's first-party UI testing framework, integrated with Xcode and built on the XCTest framework (xcui-fundamentals (opens in new window)).

"UI testing in Xcode is built on three fundamental classes: XCUIApplication, XCUIElement, XCUIElementQuery." (xcui-fundamentals (opens in new window))

The general pattern: Query → Synthesize → Assert.

"1. Query - Use XCUIElementQuery to find an XCUIElement 2. Synthesize - Synthesize an event and send it to the XCUIElement 3. Assert - Use an assertion to compare the element's state against expected reference state" (xcui-fundamentals (opens in new window))

When to use

  • An iOS app needs UI tests using Apple's native framework.
  • A team prefers no external runtime (Appium, Detox) and wants Xcode-native test integration.
  • The app is iOS-only and there's no need for cross-platform reuse.

If the app is React Native, see detox-testing. For cross-platform Appium-style coverage, see appium-testing.

macOS desktop apps

The same framework tests macOS desktop apps (AppKit, SwiftUI, Catalyst) - every step below carries over. The macOS delta - -destination 'platform=macOS' flags, TCC privacy-permission resets (tccutil reset), per-device performance baselines, and headless-runner constraints - is in references/macos.md.

Step 1 - Add a UI test target

In Xcode: File → New → Target → UI Testing Bundle. The template generates a .swift test class with a default setUp:

import XCTest

final class CartUITests: XCTestCase {
    override func setUpWithError() throws {
        continueAfterFailure = false   // critical (default)
        XCUIApplication().launch()
    }
}

Per xcui-fundamentals (opens in new window):

"continueAfterFailure = NO is the default (recommended) because UI test steps are dependent on previous steps."

Step 2 - Set accessibility identifiers in production code

XCUITest finds elements via the accessibility tree. Hard-coded labels / text are brittle - set explicit identifiers in the SUT:

// Production code
let placeOrderButton = UIButton()
placeOrderButton.accessibilityIdentifier = "place-order-button"

Then in tests:

let app = XCUIApplication()
app.buttons["place-order-button"].tap()

accessibilityIdentifier (not accessibilityLabel) - labels are user-facing and translated; identifiers are dev-only and stable.

Step 3 - Query patterns

let app = XCUIApplication()

// By accessibility identifier (preferred)
app.buttons["place-order-button"].tap()
app.textFields["email-field"].typeText("user@example.com")

// By type + text
app.staticTexts["Welcome"].swipeUp()

// Predicate-based query
let cells = app.tables.cells.matching(
    NSPredicate(format: "label CONTAINS[c] 'BOOK-001'")
)
cells.element(boundBy: 0).tap()

// Wait for an element
XCTAssert(app.staticTexts["Order confirmed"].waitForExistence(timeout: 5))

Step 4 - Synthesize events

// Tap
app.buttons["submit"].tap()

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

// Swipe / drag
app.cells.element(boundBy: 0).swipeLeft()

// Pinch
app.images["map"].pinch(withScale: 2.0, velocity: 1.0)

// Press for duration (long-press)
app.buttons["context-menu"].press(forDuration: 1.0)

// System keyboard return
app.keyboards.buttons["return"].tap()

Step 5 - Assert state

let confirmation = app.staticTexts["Order confirmed"]
XCTAssertTrue(confirmation.exists)

XCTAssertEqual(app.staticTexts["order-id"].label, "ORD-12345")

XCTAssertTrue(app.buttons["submit"].isEnabled)

exists returns immediately; waitForExistence(timeout:) waits up to N seconds. Use waitForExistence for any post-tap state that depends on async work.

Step 6 - Run

# From the project directory:
xcodebuild test \
  -project MyApp.xcodeproj \
  -scheme MyApp \
  -destination 'platform=iOS Simulator,name=iPhone 15,OS=latest' \
  -resultBundlePath TestResults.xcresult

Per-destination patterns:

UseDestination string
Latest sim'platform=iOS Simulator,name=iPhone 15,OS=latest'
Specific OS'platform=iOS Simulator,name=iPhone 14,OS=17.4'
Connected device'platform=iOS,id=<UDID>'
Multi-device matrixPass -destination multiple times.

Step 7 - Parse .xcresult

The result bundle is binary. Extract via xcresulttool:

xcrun xcresulttool get test-results summary --path TestResults.xcresult --format json > results.json

Then use junit-xml-analysis (in the qa-test-reporting plugin) on the JUnit-equivalent shape (or directly on the JSON for richer data).

Step 8 - CI integration

# .github/workflows/ios-tests.yml
jobs:
  ui-tests:
    runs-on: macos-15
    steps:
      - uses: actions/checkout@v5
      - run: xcodebuild test -project MyApp.xcodeproj -scheme MyApp \
              -destination 'platform=iOS Simulator,name=iPhone 15,OS=latest' \
              -resultBundlePath TestResults.xcresult
      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: xcresult
          path: TestResults.xcresult

GitHub Actions provides macos-15 runners with Xcode pre-installed.

Anti-patterns

Anti-patternWhy it failsFix
Querying by accessibilityLabelLabels are translated; tests fail in non-English locales.Use accessibilityIdentifier (Step 2-3).
Thread.sleep for async waitsFlaky on slow runners; slow on fast.waitForExistence(timeout:) (Step 5).
continueAfterFailure = trueOne failure cascades into N false positives.Default false (Step 1).
Hard-coded text in queriesCopy changes break tests.accessibility identifiers (Step 2).
Running against wrong destination ("latest" without pin)Test passes on Xcode 16, fails on 15; reproducibility issues.Pin OS version explicitly in CI.
Skipping xcresult uploadFailure debugging needs the screenshots / videos in the bundle.Always upload (Step 8).

Limitations

  • macOS only. Xcode + Simulator require macOS hosts.
  • Slower than unit tests. Each test launches the app; expect 10-30s per test. Keep UI test count modest.
  • Flaky on Simulator under load. CI runners under contention produce intermittent failures; use retries cautiously (don't mask real bugs).
  • No first-party visual regression. Pair with percy-visual-regression-testing (in the qa-visual-regression plugin) or screenshot-comparison helpers.

References

  • xcui (opens in new window) - Apple's XCUITest fundamentals: three-class pattern (XCUIApplication / XCUIElement / XCUIElementQuery), Query→Synthesize→Assert, continueAfterFailure = NO default.
  • references/macos.md - the macOS desktop delta (destination flags, TCC permissions, CI, per-device baselines).
  • espresso-suite - Android sibling.
  • appium-testing - cross-platform alternative when iOS + Android share tests.
  • junit-xml-analysis - downstream parser for JUnit-converted xcresult.
Reference

XCUITest on macOS desktop apps - the macOS delta

View source (opens in a new tab) (opens in new window)

XCTest UI testing for macOS desktop apps (AppKit, SwiftUI, Catalyst) uses the same three-class XCUIApplication / XCUIElement / XCUIElementQuery pattern, the same accessibility-identifier locator strategy, and the same XCTAssert* macros as the iOS workflow in SKILL.md (opens in new window). Per Apple's Testing with Xcode - UI Testing chapter (opens in new window), "UI Testing in Xcode rests on two core technologies: the XCTest framework and Accessibility." This reference covers only what differs on macOS.

Destination flags

There is no simulator: -destination targets the host Mac directly.

# Run the full test bundle on the host Mac
xcodebuild test \
  -project MyApp.xcodeproj \
  -scheme MyApp \
  -destination 'platform=macOS' \
  -resultBundlePath build/result.xcresult

# Run a single test class / method
xcodebuild test -project MyApp.xcodeproj -scheme MyApp \
  -destination 'platform=macOS' \
  -only-testing:MyAppUITests/CheckoutUITests/testCheckoutHappyPath

Setting identifiers in macOS app code

// SwiftUI
Button("Sign In") { … }
    .accessibilityIdentifier("signInButton")

// AppKit
signInButton.setAccessibilityIdentifier("signInButton")

TCC privacy permissions

macOS gates Automation, Accessibility, and Screen Recording behind TCC (Transparency, Consent, and Control) consent prompts. The prompts are out-of-process - an XCUITest cannot click through them. Reset consent state before launch (per Jamf's TCC reset guide (opens in new window)), or pre-grant via an MDM PPPC (Privacy Preferences Policy Control) profile on managed CI fleets:

for s in Automation Accessibility ScreenCapture; do
  tccutil reset "$s" "$BUNDLE_ID" || true
done

CI on macOS runners

Hosted GitHub macOS runners are interactive sessions, so XCUIApplication launches need no extra display setup. Self-hosted headless Macs need an attached console or VNC session - XCTest UI cannot run under launchd alone.

# .github/workflows/macos-xctest.yml
jobs:
  test:
    runs-on: macos-14   # Apple Silicon
    steps:
      - uses: actions/checkout@v5
      - uses: maxim-lobanov/setup-xcode@v1
        with: { xcode-version: '15.4' }
      - run: |
          xcodebuild test \
            -project MyApp.xcodeproj \
            -scheme MyApp \
            -destination 'platform=macOS' \
            -resultBundlePath build/result.xcresult \
            -enableCodeCoverage YES
      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: xcresult
          path: build/result.xcresult

Result parsing is identical to iOS (Step 7 in SKILL.md (opens in new window)): xcrun xcresulttool for JSON summaries and attachments; the open-source xcresultparser converts .xcresult to JUnit XML for junit-xml-analysis.

macOS-specific gotchas

  • Performance baselines are per-device. Per applewt (opens in new window), performance tests fail until a baseline is set, and "baselines are stored per-device-configuration" - a baseline committed from an Intel Mac fails on Apple Silicon CI and vice versa. Commit baselines from the runner that gates.
  • Cross-process drag-and-drop between two apps is only partially reachable via XCUICoordinate; complex multi-app flows often need Appium's Mac2 driver instead.
  • Sandboxed App Store apps restrict test-time file-system writes; observe in-process via XCTestObservationCenter rather than out-of-process file diffs.
  • GPU-rendered content (Metal, CALayer-only views) publishes no accessibility children - opaque to any accessibility-tree driver.
  • Desktop driver landscape: for the Windows (UI Automation) and Linux (AT-SPI) counterparts and the cross-OS locator strategy, see desktop-test-strategy-reference in the qa-desktop plugin.
  • Our checkout tests were green through an eleven-day outage on the screen they cover
  • Our Mac app tests hang on the rack mini every night and pass when Marek watches them
  • One backend change turned up as 22 of last night's 23 failures
  • We shipped 7.3 with a broken password reset and the release page said zero failures
  • The accessibility audit is blocking our attestation and the Spanish lane is red