How do you write reliable UI tests with XCUITest?
MediumTestingAccessibility 2 min read
TL;DR
XCUITest runs your app in one process and the test in another. The test finds elements through the accessibility tree and taps, types and swipes like a user. For reliable tests:
- Find elements by
accessibilityIdentifier, not by visible text. - Wait for elements with
waitForExistence(timeout:). Never usesleep. - Control state with launch arguments (fake server, logged-in user, no animations).
- Keep UI tests few. Put most logic in fast unit tests.
UI tests use XCTest. Swift Testing does not support UI tests.
A basic test
import XCTest
final class LoginUITests: XCTestCase {
override func setUp() {
super.setUp()
continueAfterFailure = false
}
@MainActor
func testSuccessfulLogin() {
let app = XCUIApplication()
app.launchArguments = ["-uiTesting", "-mockServer"]
app.launch()
let email = app.textFields["login.email"]
XCTAssertTrue(email.waitForExistence(timeout: 5))
email.tap()
email.typeText("ana@example.com")
let password = app.secureTextFields["login.password"]
password.tap()
password.typeText("test-password")
app.buttons["login.submit"].tap()
XCTAssertTrue(app.staticTexts["home.title"].waitForExistence(timeout: 5))
}
}Set identifiers in the app
// SwiftUI
TextField("Email", text: $email)
.accessibilityIdentifier("login.email")
// UIKit
emailField.accessibilityIdentifier = "login.email"accessibilityIdentifier is not read by VoiceOver, and it does not change with language. That makes it stable.
Use launch arguments in the app
@main
struct ShopApp: App {
init() {
if ProcessInfo.processInfo.arguments.contains("-uiTesting") {
UIView.setAnimationsEnabled(false)
AppEnvironment.current = .mock // fake API, clean storage
}
}
var body: some Scene { WindowGroup { RootView() } }
}You can also pass values with app.launchEnvironment["USER"] = "premium".
A mock server or local fixtures make tests fast and stop failures from a real backend.
The Page Object pattern
Wrap screens in small types. Tests read like steps and survive UI changes.
struct LoginScreen {
let app: XCUIApplication
@discardableResult
func login(email: String, password: String) -> HomeScreen {
app.textFields["login.email"].tap()
app.textFields["login.email"].typeText(email)
app.secureTextFields["login.password"].tap()
app.secureTextFields["login.password"].typeText(password)
app.buttons["login.submit"].tap()
return HomeScreen(app: app)
}
}
// Test
LoginScreen(app: app).login(email: "ana@example.com", password: "test-password")Waiting the right way
let banner = app.staticTexts["toast.saved"]
XCTAssertTrue(banner.waitForExistence(timeout: 3))
// Wait for something to disappear
let gone = XCTNSPredicateExpectation(predicate: NSPredicate(format: "exists == false"),
object: app.activityIndicators.firstMatch)
wait(for: [gone], timeout: 5)System alerts
Permission alerts come from another process. Handle them with an interruption monitor, or tap through springboard:
let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard")
let allow = springboard.buttons["Allow"]
if allow.waitForExistence(timeout: 2) { allow.tap() }Why UI tests are flaky (and fixes)
| Cause | Fix |
|---|---|
| Timing and animations | waitForExistence, turn off animations |
| Real network | Mock server or fixtures |
| Leftover state | Reset storage on launch with a launch argument |
| Text-based queries | Accessibility identifiers |
| Order dependence | Each test launches a fresh app |
Other tools
- Recording: Xcode can record taps into test code. Good to start, then clean it up.
- Test plans: run the same tests in several languages, appearances or devices.
- Snapshot tests (for example
swift-snapshot-testing): compare screenshots of views. Faster than UI tests for layout checks. - Performance:
measure(metrics: [XCTApplicationLaunchMetric()]) { app.launch() }tracks launch time.
Common follow-up questions
- Why can't a UI test read my view model? The test runs in a separate process. It only sees the accessibility tree.
- How many UI tests should you have? Few, for critical flows (login, checkout). The test pyramid: many unit tests, some integration, few UI tests.
- How do you debug a failing UI test? Look at the screenshots and the accessibility hierarchy in the test report. Use
print(app.debugDescription). - Do accessibility labels help testing? Yes. A screen that is easy to test is often more accessible too.