| name | xcuitest |
| user-invocable | true |
| description | API reference: XCUITest. Query for element queries, waiting patterns, Swift 6 @MainActor, assertions, screenshots, launch arguments. |
| context | fork |
| agent | Explore |
XCUITest Reference
Comprehensive reference for writing reliable XCUITest UI tests in Swift 6.
Quick Reference
@MainActor
final class MyUITests: XCTestCase {
var app: XCUIApplication!
override func setUp() {
super.setUp()
continueAfterFailure = false
app = XCUIApplication()
app.launch()
}
func testExample() {
let button = app.buttons["Submit"]
XCTAssertTrue(button.waitForExistence(timeout: 5))
button.tap()
}
}
Core API Classes
XCUIApplication
Proxy for launching, monitoring, and terminating the app under test.
let app = XCUIApplication()
app.launchArguments = ["-UITest", "-DisableAnimations"]
app.launchEnvironment["API_URL"] = "https://test.example.com"
app.launch()
app.terminate()
app.activate()
app.state == .runningForeground
app.state == .runningBackground
app.state == .notRunning
XCUIElement
Represents a single UI element. Supports interactions and property queries.
let element = app.buttons["Submit"]
element.exists
element.isHittable
element.isEnabled
element.isSelected
element.label
element.value
element.identifier
element.frame
element.elementType
XCUIElementQuery
Defines search criteria for finding UI elements.
app.buttons
app.staticTexts
app.textFields
app.secureTextFields
app.switches
app.sliders
app.tables
app.cells
app.scrollViews
app.images
app.alerts
app.sheets
app.navigationBars
app.tabBars
app.toolbars
app.buttons["Submit"]
app.staticTexts["Welcome"]
app.descendants(matching: .any)
app.descendants(matching: .button)
app.descendants(matching: .staticText)
app.descendants(matching: .any).matching(identifier: "my-id").firstMatch
app.buttons.matching(NSPredicate(format: "label CONTAINS[c] 'Save'"))
app.buttons.matching(NSPredicate(format: "identifier == 'submit-btn'"))
app.staticTexts.matching(NSPredicate(format: "label BEGINSWITH 'Error'"))
query.count
query.element
query.firstMatch
query.element(boundBy: 0)
query.allElementsBoundByIndex
XCUICoordinate
Represents a screen location for coordinate-based interactions.
let center = element.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5))
let topLeft = element.coordinate(withNormalizedOffset: CGVector(dx: 0, dy: 0))
let point = app.coordinate(withNormalizedOffset: .zero)
.withOffset(CGVector(dx: 100, dy: 200))
let screenCenter = app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5))
Element Interactions
Tap Actions
element.tap()
element.doubleTap()
element.twoFingerTap()
element.tap(withNumberOfTaps: 3, numberOfTouches: 1)
Press Actions
element.press(forDuration: 1.0)
element.press(forDuration: 0.5, thenDragTo: otherElement)
Text Input
textField.tap()
textField.typeText("Hello")
textField.clearAndEnterText("New text")
textField.tap()
textField.press(forDuration: 1.0)
app.menuItems["Select All"].tap()
textField.typeText("")
Swipe Gestures
element.swipeUp()
element.swipeDown()
element.swipeLeft()
element.swipeRight()
element.swipeUp(velocity: .fast)
element.swipeUp(velocity: .slow)
Coordinate-Based Gestures
let start = cell.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0))
let end = cell.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 6))
start.press(forDuration: 0, thenDragTo: end)
let from = app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.8))
let to = app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.2))
from.press(forDuration: 0.1, thenDragTo: to)
let point = app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5))
point.tap()
Other Gestures
element.pinch(withScale: 0.5, velocity: -1)
element.pinch(withScale: 2.0, velocity: 1)
element.rotate(0.5, withVelocity: 1)
slider.adjust(toNormalizedSliderPosition: 0.7)
picker.adjust(toPickerWheelValue: "Option 3")
Waiting Mechanisms
waitForExistence (Simplest)
let exists = element.waitForExistence(timeout: 5)
XCTAssertTrue(exists, "Element did not appear")
if button.waitForExistence(timeout: 3) {
button.tap()
}
XCTWaiter (More Control)
let predicate = NSPredicate(format: "exists == true")
let expectation = XCTNSPredicateExpectation(predicate: predicate, object: element)
let result = XCTWaiter().wait(for: [expectation], timeout: 5)
switch result {
case .completed:
case .timedOut:
XCTFail("Element did not appear within timeout")
case .incorrectOrder:
case .invertedFulfillment:
case .interrupted:
@unknown default:
break
}
Wait for Non-Existence (Xcode 16+)
let loadingIndicator = app.activityIndicators["loading"]
XCTAssertTrue(loadingIndicator.waitForNonExistence(withTimeout: 10), "Loading should complete")
func waitForNonExistence(_ element: XCUIElement, timeout: TimeInterval) -> Bool {
let predicate = NSPredicate(format: "exists == false")
let expectation = XCTNSPredicateExpectation(predicate: predicate, object: element)
let result = XCTWaiter().wait(for: [expectation], timeout: timeout)
return result == .completed
}
Wait for Property Change
let predicate = NSPredicate(format: "isEnabled == true")
let expectation = XCTNSPredicateExpectation(predicate: predicate, object: button)
XCTWaiter().wait(for: [expectation], timeout: 5)
let predicate = NSPredicate(format: "label == 'Done'")
let expectation = XCTNSPredicateExpectation(predicate: predicate, object: statusLabel)
XCTWaiter().wait(for: [expectation], timeout: 10)
Multiple Expectations
let exp1 = XCTNSPredicateExpectation(predicate: pred1, object: element1)
let exp2 = XCTNSPredicateExpectation(predicate: pred2, object: element2)
XCTWaiter().wait(for: [exp1, exp2], timeout: 10, enforceOrder: false)
Wait for Property Value (Xcode 26+ / iOS 26+)
let favoriteButton = app.buttons["Favorite"]
favoriteButton.tap()
XCTAssertTrue(
favoriteButton.wait(for: \.value, toEqual: true, timeout: 10),
"Button should show favorited state"
)
XCTAssertTrue(
statusLabel.wait(for: \.label, toEqual: "Complete", timeout: 5),
"Status should update to Complete"
)
XCTAssertTrue(
submitButton.wait(for: \.isEnabled, toEqual: true, timeout: 3),
"Submit button should become enabled"
)
Note: The wait(for:toEqual:timeout:) method uses Swift KeyPaths for type-safe property access. It returns true if the property matches the expected value within the timeout, false otherwise.
Permission Handling
Reset Authorization Status
Reset permissions before tests to ensure consistent state. Call before app.launch().
override func setUp() {
super.setUp()
let app = XCUIApplication()
app.resetAuthorizationStatus(for: .location)
app.resetAuthorizationStatus(for: .camera)
app.resetAuthorizationStatus(for: .photos)
app.resetAuthorizationStatus(for: .health)
app.launch()
}
XCUIProtectedResource Types
.contacts
.calendar
.reminders
.photos
.microphone
.camera
.mediaLibrary
.homeKit
.bluetooth
.keyboardNetwork
.location
.health
Handling HealthKit Permission Dialog (iOS 26)
HealthKit authorization on iOS 26 uses a scrollable sheet with buttons below the fold:
func handleHealthKitDialog(allow: Bool = false) {
let healthAccessText = app.staticTexts["Health Access"]
if healthAccessText.waitForExistence(timeout: 5) {
let from = app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.8))
let to = app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.3))
from.press(forDuration: 0.1, thenDragTo: to)
let buttonLabel = allow ? "Allow" : "Don't Allow"
let button = app.buttons[buttonLabel]
if button.waitForExistence(timeout: 5) {
button.tap()
} else {
let fallback = app.buttons.matching(
NSPredicate(format: "label CONTAINS[c] '\(buttonLabel)'")
).firstMatch
if fallback.exists { fallback.tap() }
}
}
}
Using UI Interruption Monitor
For handling unexpected permission dialogs during tests:
override func setUp() {
super.setUp()
addUIInterruptionMonitor(withDescription: "Location Permission") { alert -> Bool in
if alert.buttons["Allow While Using App"].exists {
alert.buttons["Allow While Using App"].tap()
return true
}
if alert.buttons["Don't Allow"].exists {
alert.buttons["Don't Allow"].tap()
return true
}
return false
}
addUIInterruptionMonitor(withDescription: "Health Permission") { alert -> Bool in
return false
}
app.launch()
}
func testWithPermissions() {
app.buttons["Enable Location"].tap()
app.tap()
}
Springboard Alert Handling
For system-level alerts not caught by interruption monitor:
func handleSpringboardAlert(buttonLabel: String) {
let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard")
let alertButton = springboard.buttons[buttonLabel]
if alertButton.waitForExistence(timeout: 3) {
alertButton.tap()
}
}
handleSpringboardAlert(buttonLabel: "Allow")
handleSpringboardAlert(buttonLabel: "Don't Allow")
Swift 6 Concurrency
The Problem
Swift 6 strict concurrency requires proper actor isolation. XCTestCase methods are not main-actor-isolated by default, causing errors when accessing @MainActor objects.
Error you'll see:
Call to main actor-isolated initializer 'init()' in a synchronous nonisolated context
Solution 1: Mark Test Class with @MainActor (Recommended)
@MainActor
final class MyUITests: XCTestCase {
var app: XCUIApplication!
override func setUp() {
super.setUp()
app = XCUIApplication()
app.launch()
}
override func tearDown() {
app = nil
super.tearDown()
}
func testSomething() {
}
}
Solution 2: Async setUp/tearDown with MainActor.run
final class MyUITests: XCTestCase {
var app: XCUIApplication!
override func setUp() async throws {
try await super.setUp()
await MainActor.run {
app = XCUIApplication()
app.launch()
}
}
override func tearDown() async throws {
await MainActor.run {
app = nil
}
try await super.tearDown()
}
}
Solution 3: Mark Properties as nonisolated
For properties that don't need main actor:
@MainActor
final class MyUITests: XCTestCase {
nonisolated var testUserID: String {
ProcessInfo.processInfo.environment["TEST_USER_ID"] ?? "default"
}
}
Async Test Methods
@MainActor
func testAsyncOperation() async throws {
app.buttons["Start"].tap()
try await Task.sleep(nanoseconds: 1_000_000_000)
XCTAssertTrue(app.staticTexts["Complete"].exists)
}
Screenshots and Attachments
Take Screenshot
let screenshot = XCUIScreen.main.screenshot()
let elementShot = element.screenshot()
let attachment = XCTAttachment(screenshot: screenshot)
attachment.name = "Login Screen"
attachment.lifetime = .keepAlways
add(attachment)
Automatic Screenshot on Failure
override func tearDown() {
if let failureCount = testRun?.failureCount, failureCount > 0 {
let screenshot = XCUIScreen.main.screenshot()
let attachment = XCTAttachment(screenshot: screenshot)
attachment.name = "Failure-\(name)"
attachment.lifetime = .keepAlways
add(attachment)
}
super.tearDown()
}
Access Screenshots After Test
Screenshots are stored in the .xcresult bundle in DerivedData.
Use xcparse to extract:
brew install xcparse
xcparse screenshots /path/to/Test.xcresult /output/directory
Launch Arguments and Environment
Setting Values
let app = XCUIApplication()
app.launchArguments = ["-UITest", "-DisableAnimations"]
app.launchArguments.append("-SkipOnboarding")
app.launchEnvironment["API_URL"] = "https://test.example.com"
app.launchEnvironment["TEST_USER_ID"] = "test-user-123"
app.launch()
Reading in App Code
if ProcessInfo.processInfo.arguments.contains("-UITest") {
UIView.setAnimationsEnabled(false)
}
if let testUserID = ProcessInfo.processInfo.environment["TEST_USER_ID"] {
UserDefaults.standard.set(testUserID, forKey: "testUserID")
}
UserDefaults Override Trick
Arguments with - prefix set UserDefaults:
app.launchArguments = ["-myKey", "myValue"]
UserDefaults.standard.string(forKey: "myKey")
Localization Testing
app.launchArguments = [
"-AppleLanguages", "(es)",
"-AppleLocale", "es_ES"
]
Accessibility Testing
app.launchArguments = [
"-UIPreferredContentSizeCategoryName",
UIContentSizeCategory.accessibilityExtraExtraExtraLarge.rawValue
]
Assertions
Basic Assertions
XCTAssertTrue(element.exists)
XCTAssertFalse(element.exists)
XCTAssertEqual(element.label, "Expected")
XCTAssertNotEqual(element.value as? String, "Wrong")
XCTAssertNil(element.value)
XCTAssertNotNil(element.value)
With Custom Messages
XCTAssertTrue(element.exists, "Submit button should be visible")
XCTAssertEqual(element.label, "Done", "Button label should be 'Done' after completion")
Existence Patterns
XCTAssertTrue(element.waitForExistence(timeout: 5), "Element not found")
XCTAssertFalse(app.alerts["Error"].exists, "Unexpected error alert")
XCTAssertTrue(button.isEnabled, "Button should be enabled")
XCTAssertTrue(button.isHittable, "Button should be tappable")
System Alerts
Interruption Monitor (for system dialogs)
override func setUp() {
super.setUp()
addUIInterruptionMonitor(withDescription: "System Alert") { alert -> Bool in
if alert.buttons["Allow While Using App"].exists {
alert.buttons["Allow While Using App"].tap()
return true
}
if alert.buttons["Allow"].exists {
alert.buttons["Allow"].tap()
return true
}
if alert.buttons["Don't Allow"].exists {
alert.buttons["Don't Allow"].tap()
return true
}
return false
}
app.launch()
}
func testWithSystemAlert() {
app.buttons["Request Permission"].tap()
app.tap()
}
Direct Alert Handling
let alert = app.alerts["Confirm Delete"]
if alert.waitForExistence(timeout: 3) {
alert.buttons["Delete"].tap()
}
let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard")
let systemAlert = springboard.alerts.firstMatch
if systemAlert.waitForExistence(timeout: 3) {
systemAlert.buttons["Allow"].tap()
}
Common Patterns
Page Object Model
protocol Screen {
var app: XCUIApplication { get }
func waitForScreen(timeout: TimeInterval) -> Bool
}
struct LoginScreen: Screen {
let app: XCUIApplication
var usernameField: XCUIElement { app.textFields["username"] }
var passwordField: XCUIElement { app.secureTextFields["password"] }
var loginButton: XCUIElement { app.buttons["Login"] }
var errorLabel: XCUIElement { app.staticTexts["error-message"] }
func waitForScreen(timeout: TimeInterval = 5) -> Bool {
usernameField.waitForExistence(timeout: timeout)
}
func login(username: String, password: String) -> HomeScreen {
usernameField.tap()
usernameField.typeText(username)
passwordField.tap()
passwordField.typeText(password)
loginButton.tap()
return HomeScreen(app: app)
}
}
Reusable Helpers
extension XCUIApplication {
func waitForElement(_ identifier: String, timeout: TimeInterval = 5) -> XCUIElement {
let element = descendants(matching: .any).matching(identifier: identifier).firstMatch
XCTAssertTrue(element.waitForExistence(timeout: timeout),
"Element '\(identifier)' not found within \(timeout)s")
return element
}
func tapTab(_ identifier: String) {
let tab = buttons[identifier]
XCTAssertTrue(tab.waitForExistence(timeout: 5), "Tab '\(identifier)' not found")
tab.tap()
}
}
extension XCUIElement {
func clearAndType(_ text: String) {
guard let currentValue = value as? String, !currentValue.isEmpty else {
tap()
typeText(text)
return
}
tap()
let deleteString = String(repeating: XCUIKeyboardKey.delete.rawValue, count: currentValue.count)
typeText(deleteString)
typeText(text)
}
}
Timeout Constants
enum TestTimeout: TimeInterval {
case short = 2
case medium = 5
case long = 10
case networkLoad = 30
}
button.waitForExistence(timeout: TestTimeout.medium.rawValue)
Advanced Patterns
For more detailed patterns including:
- Base test class implementation
- iOS system dialog handling (HealthKit, Location, Notifications)
- Scroll and wait patterns
- Text field helpers
- Debugging techniques
- Test organization
See patterns.md.
Troubleshooting
Element Not Found
- Check accessibility identifier is set in code
- Use Accessibility Inspector (Xcode > Open Developer Tool)
- Print element hierarchy:
print(app.debugDescription)
- Check element is in view (may need scrolling)
- Ensure element is enabled for accessibility
Flaky Tests
- Use
waitForExistence instead of sleep
- Add
continueAfterFailure = false
- Disable animations in test setup
- Reset simulator state between tests
- Use unique test data
Swift 6 Concurrency Errors
- Mark test class with
@MainActor
- Use
nonisolated for properties that don't need main actor
- Use async setUp/tearDown if needed
- Check for Sendable conformance issues
Screenshots Not Saved
- Set
attachment.lifetime = .keepAlways
- Check DerivedData location for
.xcresult
- Use
xcparse to extract from result bundle
Permission Dialog Issues
- Use
resetAuthorizationStatus(for:) before app.launch()
- HealthKit uses a scrollable sheet on iOS 26 - must scroll to reveal buttons
addUIInterruptionMonitor requires an app interaction to trigger
- For system-level alerts, use springboard bundle identifier approach
What's New in Xcode 26 / iOS 26
New APIs
wait(for:toEqual:timeout:) - KeyPath-based waiting for any property value
waitForNonExistence(withTimeout:) - Native API to wait for element removal (Xcode 16+)
- XCTHitchMetric - Measure UI responsiveness and frame drops
Enhanced Recording
- Cleaner, more maintainable generated test code
- Multiple identifier options for element selection
- Integration with Test Report's Automation Explorer
Cross-Platform Testing
- Run automation tests across iPhone, iPad, Mac, Apple TV, Apple Watch
- Test plan configurations for multiple locales, device types, system conditions
- Video recordings and screenshots of test runs in results
Swift Concurrency Debugging
- Seamless stepping across threads in async tests
- Task ID visibility in debugger
- Thread Performance Checker integration
Sources
Apple Documentation
WWDC Sessions
Community Resources
Fetching More Apple Docs
- Search this skill's local
.md files first.
- If the topic is not here, check the other installed Apple skills you have available by their names, descriptions, or
SKILL.md frontmatter, then grep their local files. This is faster and uses less context than fetching new docs from the internet.
- If no installed skill has the page, use the relevant documentation path from the local XCTest or XCUIAutomation indexes with the
sosumi.ai Markdown mirror. For example, /documentation/xcuiautomation/xcuielement maps to https://sosumi.ai/documentation/xcuiautomation/xcuielement.