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:
File > New > Target- Choose
macOS UI Testing Bundle - 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:
- Open Accessibility Inspector
- Click the target picker and select your macOS app
- 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/testCreateNewDocumentCI 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.xcresultMonitoring 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.