8.5 — UI Testing with XCUITest

Opening scenario

The CEO’s demo broke at the worst moment: she tapped “Sign In,” and the app crashed. Your unit tests are all green. The crash was a SwiftUI state bug only triggered when the keyboard dismissed while a sheet was animating. No unit test could have caught it. This is the niche where XCUITest earns its keep — and where it costs the most maintenance.

UI tests are slow, brittle, and indispensable for a small set of paths. Pick them carefully.

Context taxonomy

ConceptContextWhy it mattersCommon confusion
XCUIApplicationLaunches the appEach test runs a full app lifecycleRe-using across tests — state leaks
XCUIElementQuery handle to a UI elementLazy; resolves at accessTreating it like a snapshot
XCUIElementQueryFilters across the element treeComposableOne mega-query — slow + brittle
Accessibility identifierStable test handleDoesn’t change with localizationUsing visible text — breaks on i18n
waitForExistence(timeout:)Wait for async UIRequired for animations/networkPolling with Thread.sleep — flaky + slow
XCUIApplication.launchArgumentsPass test config to appDisable animations, seed stateHard-coding test state in app code

Concept → Why → How → Code

Concept: XCUITest drives the simulator (or device) at the accessibility layer. It taps, swipes, and types like a user would, then asserts on visible elements.

Why: smoke tests for critical paths (sign-in, checkout, signup) catch the bugs that unit tests can’t — broken nav, missing entitlements, dead deep links, race conditions across the whole stack.

How: launch the app per test, query elements by accessibility identifier, perform gestures, assert on results with explicit waits.

Code — a complete sign-in UI test:

import XCTest

final class SignInUITests: XCTestCase {
    var app: XCUIApplication!

    override func setUpWithError() throws {
        try super.setUpWithError()
        continueAfterFailure = false
        app = XCUIApplication()
        app.launchArguments = ["-UITestMode", "1"]  // app reads this to seed test state
        app.launch()
    }

    func test_validCredentials_navigatesToHome() {
        let emailField = app.textFields["signIn.email"]
        XCTAssertTrue(emailField.waitForExistence(timeout: 2))
        emailField.tap()
        emailField.typeText("test@example.com")

        let passwordField = app.secureTextFields["signIn.password"]
        passwordField.tap()
        passwordField.typeText("correct-horse-battery-staple")

        app.buttons["signIn.submit"].tap()

        let homeTitle = app.staticTexts["home.title"]
        XCTAssertTrue(homeTitle.waitForExistence(timeout: 5))
    }

    func test_emptyForm_buttonDisabled() {
        let submit = app.buttons["signIn.submit"]
        XCTAssertTrue(submit.waitForExistence(timeout: 2))
        XCTAssertFalse(submit.isEnabled)
    }
}

Accessibility identifiers

In your SwiftUI views:

TextField("Email", text: $email)
    .accessibilityIdentifier("signIn.email")

Button("Sign In") { ... }
    .accessibilityIdentifier("signIn.submit")

In UIKit:

emailField.accessibilityIdentifier = "signIn.email"

Identifiers don’t change with localization. They don’t change when designers tweak copy. They are the single most important XCUITest hygiene practice.

Element queries

app.buttons["Submit"]                       // by label or identifier
app.buttons.matching(identifier: "submit")  // explicit by identifier
app.cells.element(boundBy: 0)               // first cell
app.staticTexts.containing(.staticText, identifier: "Welcome").element
app.scrollViews.firstMatch                  // shorthand for first match

Waiting (always explicit, never sleep)

let title = app.staticTexts["home.title"]
XCTAssertTrue(title.waitForExistence(timeout: 5))   // wait for appear

// Wait for disappear
let predicate = NSPredicate(format: "exists == false")
expectation(for: predicate, evaluatedWith: title)
waitForExpectations(timeout: 5)

// Wait for an arbitrary property
let predicate = NSPredicate(format: "isEnabled == true")
expectation(for: predicate, evaluatedWith: app.buttons["Submit"])
waitForExpectations(timeout: 5)

Launch arguments — talk to your app

Tests pass flags; the app reads them at startup and configures itself:

// In test
app.launchArguments = ["-UITestMode", "1", "-SeedUser", "premium"]

// In AppDelegate or App
if CommandLine.arguments.contains("-UITestMode") {
    UIView.setAnimationsEnabled(false)
    AppEnvironment.current = .uiTest
}

Common flags to wire in:

  • Disable animations (otherwise tests are slow + flaky)
  • Stub the network layer with canned fixtures
  • Skip onboarding
  • Sign in a test user automatically

CI-safe patterns

  • continueAfterFailure = false — stop on first failure; the rest of the test is noise.
  • Disable animationsUIView.setAnimationsEnabled(false) for UIKit; Transaction.disablesAnimations = true for SwiftUI via launch arg.
  • Network stubbing — never hit real network in UI tests. Use URLProtocol or a local stubs server.
  • Screenshots on failureadd(XCTAttachment(screenshot: app.screenshot())) in tearDownWithError.
  • Retry policyxcodebuild ... -test-iterations 2 -retry-tests-on-failure if a test flakes once, retry; if it flakes twice, fail.

Record vs hand-write

Xcode’s record-and-playback (the red record button in the test source) generates code. It’s useful for discovering element queries on a new screen. Always rewrite the recording by hand — recorded code uses fragile queries like app.buttons.element(boundBy: 3) instead of stable identifiers.

In the wild

  • Lyft wrote internally about killing 80% of their UI tests after they realized maintenance cost outweighed value — kept only ~20 smoke tests on critical flows.
  • Airbnb maintains “Mock Mode” — a build configuration that runs the app entirely against canned data for UI test runs.
  • Apple’s own apps (Calendar, Mail) use XCUITest heavily for app-wide accessibility audits.

Common misconceptions

  1. “More UI tests = more confidence.” Past 10–20 critical flows, more UI tests destroy your CI throughput.
  2. “UI tests can replace QA.” They catch regression in known paths only. Humans find novel bugs UI tests can’t.
  3. Thread.sleep(forTimeInterval:) is fine for waits.” It’s the #1 cause of flake. Always use waitForExistence or expectation(for:).
  4. “Recorded tests are good enough.” They’re a starting point. The recorded queries are unstable; always rewrite with accessibility identifiers.
  5. “UI tests can run on the unit test target.” No. UI tests need a separate target — they run in a different process from your app.

Seasoned engineer’s take

A UI test suite that takes longer than 10 minutes will be skipped. A UI test that flakes more than once a week will be XCTSkip-ed by some tired engineer and never re-enabled. Budget aggressively: 15–25 UI tests, run in parallel on simulator clones, finishing under 8 minutes. Save them for the paths that matter (auth, checkout, the top-of-funnel) and let unit tests handle everything else.

[!TIP] Parallelize UI tests with xcodebuild ... -parallel-testing-enabled YES -parallel-testing-worker-count 4. Each worker uses a cloned simulator. Speeds up runs by 3–4×.

[!WARNING] Don’t accessibilityIdentifier your way into a single global namespace. Prefix by screen (signIn.email, home.cart.button). A flat namespace becomes unmanageable around 50 elements.

Interview corner

Junior — “How do you find a button in a UI test?” Use app.buttons["identifier"] where identifier is the accessibility identifier set on the SwiftUI view or UIKit control. Avoid finding by visible text — it breaks when you localize.

Mid — “Your UI test is flaky. What’s your first step?” Look for Thread.sleep, hard-coded timeouts that are too short, animations not disabled, or assertions made before a network call completes. Replace sleep with waitForExistence, set a generous timeout (5s for animations, 10s for network), disable animations via launch argument, and stub the network layer so timing is deterministic.

Senior — “Design a UI test strategy for an app with 50+ screens.” Identify the critical flows — sign-in, signup, checkout, content creation, settings change — five to ten paths max. Write one happy-path UI test per flow plus a small set of error-state tests where the error is high-impact. Stub the network via URLProtocol injection driven by a launch argument so all tests run offline against canned fixtures. Keep accessibility identifiers prefixed by screen. Run the full UI suite on every PR with parallelization; allow one retry per test. Move long-tail UI tests to a nightly job that doesn’t block PRs. Track flake rate per test in a dashboard; auto-quarantine any test that flakes more than 5% over a 7-day window. I’d also consider snapshot tests as a cheaper alternative for visual-only assertions — UI tests should be reserved for interaction correctness.

Red flag — “I write a UI test for every story.” That’s a CI suite that takes 90 minutes by year two.

Lab preview

Lab 8.2 walks you through writing XCUITests for a pre-built login screen, including accessibility identifier setup, network stubbing, and CI-safe patterns.


Next: Snapshot Testing