How do you write UI tests that stay stable: accessibility identifiers, page objects, launch arguments, stubbed data and waiting?
Predict first
An app is launched with -FeatureFlag YES -UITesting YES. What does it read from UserDefaults, and with no arguments?
// === main.swift ===
import Foundation
let flag = UserDefaults.standard.string(forKey: "FeatureFlag") ?? "unset"
let ui = UserDefaults.standard.bool(forKey: "UITesting")
print("flag=\(flag) uiTesting=\(ui)")
- flag=YES uiTesting=true with the arguments, flag=unset uiTesting=false without
- Arguments are never visible to the app
- flag=unset both times
Predict first
Does a typical UI test (launch arguments, identifier lookup, waiting for existence) compile for iOS?
import XCTest
final class LoginUITests: XCTestCase {
@MainActor func testLogin() {
let app = XCUIApplication()
app.launchArguments = ["-UITesting", "YES"]
app.launchEnvironment = ["STUB_BACKEND": "1"]
app.launch()
let submit = app.buttons["login.submit"]
XCTAssertTrue(submit.waitForExistence(timeout: 5))
submit.tap()
}
}
- Yes
- No, XCUIApplication is macOS only
Mental model. A UI test is a mystery shopper following a script. It works if the shop is set up the same way each time and the shopper looks for the labelled door, not “the third door from the left”.
A UI test launches the real app in a separate process and drives it through the accessibility tree with XCUIApplication. Stability comes from a few habits. Find elements by accessibility identifier (app.buttons["login.submit"]), not by visible text or position: text changes with copy and localisation, positions with layout, while an identifier you set on purpose is stable and does not alter what VoiceOver reads. Wrap screens in page objects (a type per screen with methods such as login(user:password:)) so selectors live in one place and a UI change means one fix, and tests read as journeys. Wait for conditions: waitForExistence(timeout:) (or predicate expectations) never a sleep; the timeout only matters when something is really wrong.
Make the starting state known. Pass launchArguments and launchEnvironment to the app: arguments of the form -key value are also read into UserDefaults (so -UITesting YES becomes a readable default, as the check below shows), which is a simple way to switch on a test mode, skip onboarding, disable animations and select a stub backend that serves fixed responses so the test never touches the network. Reset or isolate app data (fresh install state, a per-test storage location). Keep each test independent, short and about one journey; run them from a test plan so locale, language and parallelism are configured outside the code; and enable parallel testing on cloned simulators. Record failures with screenshots and the result bundle, and keep the suite small (the pyramid): critical flows only, because every UI test is a maintenance cost and a flake risk.
Command-line arguments of the form -key value populate the argument domain of UserDefaults, which is why they are a handy test switch.
These are the standard XCUITest APIs available on iOS.
Common traps
- Querying by label text or index. A copy edit or layout change breaks the test for no real reason; use identifiers.
- Letting the test hit the real backend. Network state makes it slow and random; serve stubbed responses in test mode.
They might follow up with
- How does the app know it is under test? A launch argument or environment variable set by the test, read at startup to choose stubs and disable animation.
- Why a page object? Selectors in one place, readable tests, and a single fix when the screen changes.
Related questions
- 22.5.1 How do snapshot tests work, and what are their pitfalls: rendering differences, dynamic content, size, and…
- 22.6.1 How do you test SwiftUI: view models, hosting a view, ImageRenderer snapshots and ViewInspector?
- 22.7.1 Why do tests flake, how do you find and fix them, and what makes a CI test run hermetic?
- 22.1.1 What is the test pyramid for an iOS app, and what does each layer cost and catch?
← How do snapshot tests work, and what are their pitfalls: rendering…