XCUITest Tutorial: iOS UI Automation with Xcode
XCUITest is Apple's first-party UI testing framework for iOS (and macOS). It runs in a separate process from the app, controlling it via the Accessibility framework. Tests are written in Swift (or Objective-C) and run in Xcode or CI via xcodebuild. This guide covers setup, element queries, gesture simulation, launch arguments for test configuration, and patterns for maintainable iOS UI tests.
Key Takeaways
XCUITest uses the Accessibility tree, not the view hierarchy. Elements are found by accessibility identifier, label, or type — not by UIKit class or frame. Set accessibilityIdentifier on elements you plan to test.
Launch arguments make tests independent of network state. Pass flags like --useMockData in XCUIApplication.launchArguments to configure the app for testing. The app reads these at launch and switches to mock data sources.
waitForExistence() replaces explicit sleeps. Instead of sleep(2), use element.waitForExistence(timeout: 5). It polls until the element appears or the timeout expires.
Page Object Model keeps tests readable. Encapsulate element queries and actions in page objects. Tests read as user stories; implementation details stay in the page object.
Test in CI with iPhone Simulator. Xcode Cloud, GitHub Actions with macOS runners, and Bitrise all support running XCUITests on simulators. Physical device CI is possible but more expensive.
Setting Up XCUITest
XCUITest tests live in a separate target from your main app. To add a UI test target:
- In Xcode: File → New → Target → UI Testing Bundle
- Name it
YourAppUITests - The target is pre-configured with the
XCTestframework
Your UI test files go in this target. They're separate from unit tests (which are in a YourAppTests target).
A minimal UI test file:
import XCTest
final class LoginUITests: XCTestCase {
var app: XCUIApplication!
override func setUpWithError() throws {
continueAfterFailure = false
app = XCUIApplication()
app.launch()
}
override func tearDownWithError() throws {
app.terminate()
}
func testSuccessfulLoginNavigatesToHome() throws {
// Test implementation here
}
}continueAfterFailure = false stops the test at the first failure rather than continuing. This is usually what you want — a test with broken state produces misleading subsequent failures.
Finding Elements with XCUIElementQuery
XCUITest provides element queries based on element type and accessibility properties.
Element Types
// Buttons
app.buttons["Submit"]
app.buttons.matching(identifier: "submit_button").firstMatch
// Text fields
app.textFields["email_input"]
app.secureTextFields["password_input"] // For password fields
// Labels (UILabel)
app.staticTexts["Welcome back!"]
app.staticTexts.containing(.staticText, identifier: "error_message").firstMatch
// Navigation bars
app.navigationBars["Profile"]
app.navigationBars.buttons["Back"]
// Tables and cells
app.tables.cells.firstMatch
app.tables.cells.element(boundBy: 2) // 3rd cell (0-indexed)
app.tables.cells.containing(.staticText, identifier: "Order #123").firstMatch
// Other common types
app.switches["notifications_toggle"]
app.sliders["volume_slider"]
app.segmentedControls.buttons["Monthly"]Setting Accessibility Identifiers
Queries by accessibilityIdentifier are more reliable than queries by displayed text (which changes with localization):
// In your app code
emailTextField.accessibilityIdentifier = "email_input"
submitButton.accessibilityIdentifier = "submit_button"
errorLabel.accessibilityIdentifier = "error_label"
// In SwiftUI
TextField("Email", text: $email)
.accessibilityIdentifier("email_input")Then in tests:
app.textFields["email_input"] // Query by accessibility identifier
app.buttons["submit_button"] // Stable across localization changesInteracting with Elements
// Tap
app.buttons["login_button"].tap()
// Type text
let emailField = app.textFields["email_input"]
emailField.tap()
emailField.typeText("alice@example.com")
// Clear and retype
emailField.tap()
emailField.clearAndTypeText("new@example.com") // Custom extension (see below)
// Swipe gestures
app.tables.cells.firstMatch.swipeLeft()
app.collectionViews.firstMatch.swipeUp()
// Scroll
app.scrollViews.firstMatch.swipeUp()
// Tap a specific coordinate (for cases where the element isn't accessible)
app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.8)).tap()clearAndTypeText isn't built in — add this extension:
extension XCUIElement {
func clearAndTypeText(_ text: String) {
guard let currentValue = value as? String else {
tap()
typeText(text)
return
}
tap()
let deleteString = String(repeating: XCUIKeyboardKey.delete.rawValue, count: currentValue.count)
typeText(deleteString)
typeText(text)
}
}Assertions
// Element exists
XCTAssertTrue(app.staticTexts["Welcome, Alice!"].exists)
// Element is displayed (exists AND hittable)
XCTAssertTrue(app.buttons["submit_button"].isHittable)
// Element doesn't exist
XCTAssertFalse(app.staticTexts["error_message"].exists)
// Element has value
XCTAssertEqual(app.textFields["email_input"].value as? String, "alice@example.com")
// Button is enabled
XCTAssertTrue(app.buttons["submit_button"].isEnabled)
// Count of cells
XCTAssertEqual(app.tables.cells.count, 3)Waiting for Elements (No More sleep())
For async operations (network calls, animations), use waitForExistence():
// Wait up to 5 seconds for the element to appear
let successBanner = app.staticTexts["Order placed successfully!"]
XCTAssertTrue(successBanner.waitForExistence(timeout: 5))For elements that should disappear (loading indicators):
let loadingSpinner = app.activityIndicators["loading"]
// Wait for spinner to disappear (up to 10 seconds)
let spinnerGone = NSPredicate(format: "exists == false")
expectation(for: spinnerGone, evaluatedWith: loadingSpinner)
waitForExpectations(timeout: 10)Or a helper method:
func waitForElementToDisappear(_ element: XCUIElement, timeout: TimeInterval = 5) {
let notExists = NSPredicate(format: "exists == false")
expectation(for: notExists, evaluatedWith: element)
waitForExpectations(timeout: timeout)
}Launch Arguments for Test Configuration
Launch arguments let you configure the app for testing without modifying production code:
// In test setUp
app = XCUIApplication()
app.launchArguments = ["--ui-testing", "--mock-network", "--skip-onboarding"]
app.launchEnvironment = ["TEST_USER_EMAIL": "alice@example.com"]
app.launch()In the app:
// AppDelegate or early in app initialization
#if DEBUG
if CommandLine.arguments.contains("--ui-testing") {
// Configure for testing
NetworkLayer.shared.useMockResponses = true
}
if CommandLine.arguments.contains("--skip-onboarding") {
UserDefaults.standard.set(true, forKey: "hasCompletedOnboarding")
}
#endifThis pattern keeps the app in a known state for each test without relying on network connectivity or requiring the test to navigate through the onboarding flow.
A Complete Login Test
final class LoginUITests: XCTestCase {
var app: XCUIApplication!
override func setUpWithError() throws {
continueAfterFailure = false
app = XCUIApplication()
app.launchArguments = ["--ui-testing", "--mock-network"]
app.launch()
}
func testSuccessfulLoginNavigatesToHomeScreen() throws {
// Navigate to login (if not already there)
if app.buttons["Get Started"].exists {
app.buttons["Get Started"].tap()
}
// Enter credentials
let emailField = app.textFields["email_input"]
emailField.tap()
emailField.typeText("alice@example.com")
let passwordField = app.secureTextFields["password_input"]
passwordField.tap()
passwordField.typeText("securepassword")
// Dismiss keyboard and submit
app.keyboards.buttons["Return"].tap()
app.buttons["login_button"].tap()
// Wait for home screen to appear
let homeScreen = app.tabBars["main_tab_bar"]
XCTAssertTrue(homeScreen.waitForExistence(timeout: 5))
// Verify user name shown in header
XCTAssertTrue(app.staticTexts.containing(NSPredicate(format: "label CONTAINS 'Alice'")).firstMatch.exists)
}
func testLoginShowsErrorForInvalidCredentials() throws {
let emailField = app.textFields["email_input"]
emailField.tap()
emailField.typeText("wrong@example.com")
let passwordField = app.secureTextFields["password_input"]
passwordField.tap()
passwordField.typeText("wrongpassword")
app.buttons["login_button"].tap()
// Error message should appear
let errorLabel = app.staticTexts["error_label"]
XCTAssertTrue(errorLabel.waitForExistence(timeout: 5))
XCTAssertEqual(errorLabel.label, "Invalid email or password")
// Should remain on login screen
XCTAssertTrue(app.buttons["login_button"].exists)
}
func testLoginButtonDisabledWhenFieldsEmpty() throws {
XCTAssertFalse(app.buttons["login_button"].isEnabled)
app.textFields["email_input"].tap()
app.textFields["email_input"].typeText("a")
// Still disabled (password empty)
XCTAssertFalse(app.buttons["login_button"].isEnabled)
}
}Page Object Pattern
For larger apps, create screen objects that encapsulate element queries:
// LoginScreen.swift
struct LoginScreen {
let app: XCUIApplication
var emailField: XCUIElement { app.textFields["email_input"] }
var passwordField: XCUIElement { app.secureTextFields["password_input"] }
var loginButton: XCUIElement { app.buttons["login_button"] }
var errorLabel: XCUIElement { app.staticTexts["error_label"] }
@discardableResult
func enterEmail(_ email: String) -> LoginScreen {
emailField.tap()
emailField.typeText(email)
return self
}
@discardableResult
func enterPassword(_ password: String) -> LoginScreen {
passwordField.tap()
passwordField.typeText(password)
return self
}
func tapLogin() -> HomeScreen {
loginButton.tap()
return HomeScreen(app: app)
}
func verifyErrorMessage(_ message: String) -> LoginScreen {
XCTAssertTrue(errorLabel.waitForExistence(timeout: 5))
XCTAssertEqual(errorLabel.label, message)
return self
}
}
// HomeScreen.swift
struct HomeScreen {
let app: XCUIApplication
var isVisible: Bool { app.tabBars["main_tab_bar"].waitForExistence(timeout: 5) }
var greetingText: XCUIElement { app.staticTexts["greeting_label"] }
}Tests become readable:
func testSuccessfulLogin() throws {
let home = LoginScreen(app: app)
.enterEmail("alice@example.com")
.enterPassword("securepassword")
.tapLogin()
XCTAssertTrue(home.isVisible)
}Running Tests in CI
# Run all UI tests on an iPhone 15 simulator
xcodebuild <span class="hljs-built_in">test \
-scheme YourApp \
-destination <span class="hljs-string">"platform=iOS Simulator,name=iPhone 15,OS=17.0" \
-testPlan YourApp
<span class="hljs-comment"># Run specific test class
xcodebuild <span class="hljs-built_in">test \
-scheme YourApp \
-destination <span class="hljs-string">"platform=iOS Simulator,name=iPhone 15,OS=17.0" \
-only-testing:YourAppUITests/LoginUITests
<span class="hljs-comment"># Export results in JUnit XML format (for CI reporting)
xcodebuild <span class="hljs-built_in">test \
-scheme YourApp \
-destination <span class="hljs-string">"platform=iOS Simulator,name=iPhone 15" \
-resultBundlePath TestResults.xcresult
xcrun xcresulttool get --format json --path TestResults.xcresultFor GitHub Actions, the macOS runner has Xcode installed:
- name: Run UI Tests
run: |
xcodebuild test \
-scheme YourApp \
-destination "platform=iOS Simulator,name=iPhone 15" \
-resultBundlePath TestResults.xcresult
- name: Upload test results
uses: actions/upload-artifact@v3
if: always()
with:
name: test-results
path: TestResults.xcresultSummary
XCUITest is the right choice for iOS UI testing when you own the app. It runs close to the metal — using Apple's Accessibility framework — and integrates naturally with Xcode and CI pipelines.
The key practices: use accessibilityIdentifier for stable element queries, use waitForExistence() instead of sleep(), pass launch arguments for test configuration, and use page objects to keep tests readable.
Well-written XCUITest suites run in 10-15 minutes for most apps and catch the regressions that matter: flows breaking, navigation failing, state not persisting. They're the first line of defense for user-facing iOS behavior.