iOS UI Testing with XCUITest: Element Queries, Gestures, and CI
XCUITest is Apple's framework for automating iOS UI interactions. Tests launch the app in a simulator or on a real device, interact with it like a user would, and assert on what appears on screen. Unlike unit tests, UI tests can catch integration issues — broken navigation, missing screens, and layout bugs that only appear with real views.
Setup
Add a UI Testing Bundle target to your project (File → New → Target → UI Testing Bundle). Xcode creates a target with an XCUIApplication launch call:
import XCTest
final class AppUITests: XCTestCase {
let app = XCUIApplication()
override func setUpWithError() throws {
continueAfterFailure = false
app.launch()
}
override func tearDownWithError() throws {
app.terminate()
}
}continueAfterFailure = false stops the test immediately after the first failure — UI tests are expensive, no point continuing after a broken state.
Finding Elements
XCUITest finds elements through queries on XCUIApplication:
// By type
let button = app.buttons["Login"]
let textField = app.textFields["Email"]
let label = app.staticTexts["Welcome"]
let cell = app.cells.firstMatch
// By index
let firstButton = app.buttons.element(boundBy: 0)
// By accessibility identifier
let submitButton = app.buttons["submitButton"] // set via .accessibilityIdentifier in codeSetting Accessibility Identifiers
In your SwiftUI or UIKit code, set identifiers for reliable test targeting:
SwiftUI:
Button("Login") { login() }
.accessibilityIdentifier("loginButton")UIKit:
loginButton.accessibilityIdentifier = "loginButton"Accessibility identifiers survive text changes and localization — more stable than querying by text.
Element Queries
The query API builds hierarchical searches:
// Direct child
let header = app.navigationBars["Dashboard"]
let doneButton = header.buttons["Done"]
// Descendant at any depth
let passwordField = app.descendants(matching: .secureTextField).firstMatch
// Predicate queries
let errorLabels = app.staticTexts.matching(NSPredicate(format: "label CONTAINS 'Error'"))
// Combine conditions
let enabledButtons = app.buttons.matching(NSPredicate(format: "isEnabled == true"))Interactions
Tapping
app.buttons["Login"].tap()
app.cells.element(boundBy: 0).tap()
app.buttons["Login"].doubleTap()
app.buttons["Delete"].press(forDuration: 1.0) // long pressText Input
let emailField = app.textFields["Email"]
emailField.tap()
emailField.typeText("user@example.com")
// Clear existing text first
emailField.clearAndEnterText("new@example.com") // custom extensionCustom extension for clearing:
extension XCUIElement {
func clearAndEnterText(_ text: String) {
guard let stringValue = self.value as? String, !stringValue.isEmpty else {
tap()
typeText(text)
return
}
tap()
let selectAll = XCUIApplication().menuItems["Select All"]
if selectAll.waitForExistence(timeout: 1) {
selectAll.tap()
typeText(text)
} else {
let deleteString = String(repeating: XCUIKeyboardKey.delete.rawValue, count: stringValue.count)
typeText(deleteString)
typeText(text)
}
}
}Swipe and Scroll
app.swipeUp()
app.swipeLeft()
app.tables.element.swipeDown()
// Scroll to an element
app.tables.cells["Last Item"].scrollIntoView()Waiting for Elements
UI tests interact with async operations. Use waitForExistence instead of sleep:
let loadingSpinner = app.activityIndicators.firstMatch
XCTAssertTrue(loadingSpinner.waitForExistence(timeout: 2))
// Wait for spinner to disappear
let expectation = XCTNSPredicateExpectation(
predicate: NSPredicate(format: "exists == false"),
object: loadingSpinner
)
wait(for: [expectation], timeout: 10)
// Check element is visible after data loads
XCTAssertTrue(app.staticTexts["Alice"].waitForExistence(timeout: 5))A Complete Login Test
func testSuccessfulLogin() {
// Given
let emailField = app.textFields["emailInput"]
let passwordField = app.secureTextFields["passwordInput"]
let loginButton = app.buttons["loginButton"]
// When
emailField.tap()
emailField.typeText("user@example.com")
passwordField.tap()
passwordField.typeText("correctPassword")
loginButton.tap()
// Then
XCTAssertTrue(
app.navigationBars["Dashboard"].waitForExistence(timeout: 5),
"Dashboard should appear after successful login"
)
}
func testLoginWithInvalidCredentials() {
app.textFields["emailInput"].tap()
app.textFields["emailInput"].typeText("wrong@example.com")
app.secureTextFields["passwordInput"].tap()
app.secureTextFields["passwordInput"].typeText("wrongPassword")
app.buttons["loginButton"].tap()
XCTAssertTrue(
app.staticTexts["Invalid credentials"].waitForExistence(timeout: 3),
"Error message should appear for invalid credentials"
)
}Testing Alerts
func testLogoutConfirmation() {
// Navigate to settings
app.tabBars.buttons["Settings"].tap()
app.buttons["Logout"].tap()
// Verify alert appears
let alert = app.alerts["Confirm Logout"]
XCTAssertTrue(alert.waitForExistence(timeout: 2))
// Tap confirm
alert.buttons["Logout"].tap()
// Verify returned to login
XCTAssertTrue(app.textFields["emailInput"].waitForExistence(timeout: 3))
}Screenshots in Tests
Capture screenshots on failure for debugging:
func testCheckout() {
navigateToCart()
let screenshot = app.screenshot()
let attachment = XCTAttachment(screenshot: screenshot)
attachment.lifetime = .keepAlways
add(attachment)
app.buttons["Checkout"].tap()
// ...
}In CI, screenshots are stored in the .xcresult bundle and viewable in Xcode's Report Navigator.
Launch Arguments and Environment Variables
Pass configuration to the app during UI tests:
override func setUpWithError() throws {
continueAfterFailure = false
app.launchArguments = ["--uitesting", "--reset-state"]
app.launchEnvironment = ["API_URL": "https://staging.api.example.com"]
app.launch()
}Read in the app:
if CommandLine.arguments.contains("--uitesting") {
// Use mock data, disable animations
UIView.setAnimationsEnabled(false)
}Disabling animations makes UI tests significantly faster and less flaky.
Running in CI
xcodebuild test \
-scheme MyApp \
-testPlan UITests \
-destination <span class="hljs-string">'platform=iOS Simulator,name=iPhone 16,OS=18.0' \
-resultBundlePath TestResults.xcresult \
-parallel-testing-enabled YES \
-maximum-parallel-testing-workers 4Parallel testing runs test classes simultaneously on multiple simulator instances — substantially reduces total CI time.
Flakiness Prevention
UI tests are inherently more flaky than unit tests. Mitigation strategies:
- Always use
waitForExistence, neversleep - Disable animations via launch arguments
- Use accessibility identifiers, not text that can change
- Avoid pixel-perfect assertions — prefer existence checks
- Reset app state in
setUpto ensure test independence - Retry flaky tests in CI with
--retry-tests-on-failureflag
Production Monitoring Beyond CI
XCUITest validates UI flows in simulators. For continuous monitoring of your live iOS backend APIs — the services your app depends on — HelpMeTest tests endpoints 24/7 and alerts on failures before users report them.
Summary
- Set
continueAfterFailure = falseto stop tests on first failure - Use accessibility identifiers for stable element queries
waitForExistence(timeout:)instead ofsleepfor async operations- Pass launch arguments to configure the app for testing (disable animations, use mock data)
- Parallel testing with
-parallel-testing-enabled YESreduces CI time - Screenshots and attachments aid debugging; stored in
.xcresult