iosNudge

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 use sleep.
  • 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)

CauseFix
Timing and animationswaitForExistence, turn off animations
Real networkMock server or fixtures
Leftover stateReset storage on launch with a launch argument
Text-based queriesAccessibility identifiers
Order dependenceEach 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.