macOS UI Testing with XCUITest and Accessibility Inspector

macOS UI Testing with XCUITest and Accessibility Inspector

macOS applications built with AppKit or SwiftUI can be automated using XCUITest — the same framework used for iOS UI testing. If you're building a macOS app with Xcode, XCUITest is the native way to write end-to-end tests that launch your app, interact with UI elements, and verify behavior, all within the Xcode test runner.

What XCUITest Provides for macOS

XCUITest on macOS works through the Accessibility framework — the same system that powers VoiceOver. It reads the accessibility tree of your running application and provides an API to:

  • Launch and terminate the app
  • Find UI elements by type, label, or identifier
  • Simulate clicks, typing, key presses, and scrolling
  • Assert on element states (exists, enabled, selected, visible)
  • Capture screenshots at any point

Setting Up UI Tests

In Xcode, add a UI Test target to your project:

  1. File > New > Target
  2. Choose macOS UI Testing Bundle
  3. Set the target application to your app

This creates a test file with the basic structure:

import XCTest

class MyAppUITests: XCTestCase {
    let app = XCUIApplication()

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

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

    func testExample() throws {
        // Test code here
    }
}

continueAfterFailure = false stops the test at the first failure — usually what you want, since subsequent steps often depend on earlier ones succeeding.

Querying Elements

XCUITest provides element queries based on type and accessibility properties:

// By type
app.buttons["Save"]          // button with "Save" as accessibility label
app.textFields["Email"]      // text field labeled "Email"
app.menuItems["New"]         // menu item
app.windows.firstMatch       // first window

// By accessibility identifier (most reliable)
app.buttons["saveButton"]    // matches AutomationIdentifier/accessibilityIdentifier

// By predicate
let button = app.buttons.matching(
    NSPredicate(format: "label CONTAINS 'Save'")
).firstMatch

// By index
app.tables.firstMatch.cells.element(boundBy: 0)

// Chaining
app.windows["Main Window"].groups["toolbar"].buttons["New"]

Set accessibility identifiers in your app code for reliable automation:

// SwiftUI
Button("Save") { save() }
    .accessibilityIdentifier("saveButton")

// AppKit
saveButton.setAccessibilityIdentifier("saveButton")

Basic Interactions

func testCreateNewDocument() throws {
    // Click a button
    app.buttons["New Document"].click()

    // Type text
    let titleField = app.textFields["documentTitle"]
    titleField.click()
    titleField.typeText("My New Document")

    // Press keyboard shortcut
    app.typeKey("s", modifierFlags: .command)  // Cmd+S

    // Verify result
    XCTAssert(app.staticTexts["My New Document"].exists)
}

Using Accessibility Inspector

Accessibility Inspector (in /Applications/Xcode.app/Contents/Applications/Accessibility Inspector.app) is essential for discovering element identifiers.

How to use it:

  1. Open Accessibility Inspector
  2. Click the target picker and select your macOS app
  3. Move your cursor over UI elements — the inspector shows:
    • Role — Button, TextField, Window, etc.
    • Label — what VoiceOver reads
    • Identifier — the accessibility identifier (if set)
    • Value — current content

Hierarchy view: The tree on the left shows the full accessibility hierarchy, letting you understand parent-child relationships for more specific queries.

The identifier from Accessibility Inspector maps directly to XCUITest's query:

  • Identifier: "saveButton"app.buttons["saveButton"]
  • Label: "Save"app.buttons["Save"]

Testing Menu Bar Actions

macOS apps often expose functionality through menus:

func testFileMenuNewCreatesDocument() throws {
    // Open File menu
    app.menuBars.menuBarItems["File"].click()

    // Click New
    app.menuBars.menuBarItems["File"].menuItems["New"].click()

    // Verify new document appears
    let windows = app.windows.matching(
        NSPredicate(format: "title CONTAINS 'Untitled'")
    )
    XCTAssertEqual(windows.count, 1)
}

func testKeyboardShortcutOpensNewDocument() throws {
    let initialWindowCount = app.windows.count

    app.typeKey("n", modifierFlags: .command)

    XCTAssertEqual(app.windows.count, initialWindowCount + 1)
}

Testing Dialogs and Sheets

Modal dialogs and sheets appear attached to windows:

func testDeleteConfirmationDialog() throws {
    // Select an item
    app.tables.firstMatch.cells["DocumentRow"].click()

    // Delete it
    app.typeKey(XCUIKeyboardKey.delete, modifierFlags: [])

    // Confirm deletion in the sheet
    let sheet = app.sheets.firstMatch
    XCTAssert(sheet.waitForExistence(timeout: 2))
    XCTAssert(sheet.staticTexts["Are you sure?"].exists)

    sheet.buttons["Delete"].click()

    // Verify deleted
    XCTAssertFalse(app.tables.firstMatch.cells["DocumentRow"].exists)
}

Assertions

// Existence
XCTAssert(element.exists)
XCTAssertFalse(element.exists)

// Wait for element (handles async operations)
XCTAssert(element.waitForExistence(timeout: 5))

// State
XCTAssert(element.isEnabled)
XCTAssert(element.isSelected)

// Value
XCTAssertEqual(textField.value as? String, "expected text")

// Count
XCTAssertEqual(app.tables.firstMatch.cells.count, 10)

Handling Async Loading

Use waitForExistence when elements appear after async operations:

func testDataLoadsAfterSync() throws {
    app.buttons["Sync Now"].click()

    // Wait up to 10 seconds for data to appear
    let loadedContent = app.staticTexts["Last synced: just now"]
    XCTAssert(loadedContent.waitForExistence(timeout: 10))
}

Screenshots in Tests

Capture screenshots for debugging and documentation:

func testScreenshotExample() throws {
    let screenshot = app.windows.firstMatch.screenshot()
    let attachment = XCTAttachment(screenshot: screenshot)
    attachment.name = "Main Window State"
    attachment.lifetime = .keepAlways
    add(attachment)
}

Screenshots appear in Xcode's test results viewer.

Running UI Tests

# From command line
xcodebuild <span class="hljs-built_in">test \
  -project MyApp.xcodeproj \
  -scheme MyApp \
  -testPlan MyTestPlan \
  -destination <span class="hljs-string">"platform=macOS"

<span class="hljs-comment"># Run specific test class
xcodebuild <span class="hljs-built_in">test -project MyApp.xcodeproj -scheme MyApp \
  -only-testing:MyAppUITests/MyAppUITests/testCreateNewDocument

CI Integration

GitHub Actions with a macOS runner:

jobs:
  ui-tests:
    runs-on: macos-14
    steps:
      - uses: actions/checkout@v4
      - name: Run UI tests
        run: |
          xcodebuild test \
            -project MyApp.xcodeproj \
            -scheme MyApp \
            -destination "platform=macOS" \
            -resultBundlePath TestResults.xcresult

      - name: Upload results
        uses: actions/upload-artifact@v4
        with:
          name: test-results
          path: TestResults.xcresult

Monitoring in Production

XCUITest validates your app's UI in CI. For monitoring your macOS app's backend APIs and services in production, HelpMeTest provides continuous health monitoring with alerts — so you know about API degradation before users experience it in your app.

Summary

XCUITest for macOS provides a first-class testing experience when you're building in Xcode:

  • Accessibility Inspector — discover element identifiers without guessing
  • Accessibility identifiers — set them in your app code for reliable automation
  • waitForExistence — handle async UI updates without flaky sleeps
  • Menu bar testing — click menus, verify keyboard shortcuts work
  • Sheet/dialog testing — verify confirmation flows
  • CI via xcodebuild — runs on GitHub Actions macOS runners

The key investment is ensuring your app has good accessibility identifier coverage — it improves both testability and VoiceOver support at the same time.

Read more