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
| Concept | Context | Why it matters | Common confusion |
|---|---|---|---|
XCUIApplication | Launches the app | Each test runs a full app lifecycle | Re-using across tests — state leaks |
XCUIElement | Query handle to a UI element | Lazy; resolves at access | Treating it like a snapshot |
XCUIElementQuery | Filters across the element tree | Composable | One mega-query — slow + brittle |
| Accessibility identifier | Stable test handle | Doesn’t change with localization | Using visible text — breaks on i18n |
waitForExistence(timeout:) | Wait for async UI | Required for animations/network | Polling with Thread.sleep — flaky + slow |
XCUIApplication.launchArguments | Pass test config to app | Disable animations, seed state | Hard-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 animations —
UIView.setAnimationsEnabled(false)for UIKit;Transaction.disablesAnimations = truefor SwiftUI via launch arg. - Network stubbing — never hit real network in UI tests. Use
URLProtocolor a local stubs server. - Screenshots on failure —
add(XCTAttachment(screenshot: app.screenshot()))intearDownWithError. - Retry policy —
xcodebuild ... -test-iterations 2 -retry-tests-on-failureif 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
- “More UI tests = more confidence.” Past 10–20 critical flows, more UI tests destroy your CI throughput.
- “UI tests can replace QA.” They catch regression in known paths only. Humans find novel bugs UI tests can’t.
- “
Thread.sleep(forTimeInterval:)is fine for waits.” It’s the #1 cause of flake. Always usewaitForExistenceorexpectation(for:). - “Recorded tests are good enough.” They’re a starting point. The recorded queries are unstable; always rewrite with accessibility identifiers.
- “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
accessibilityIdentifieryour 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