| name | ios-ui-recording |
| description | Use when setting up UI test recording in Xcode 26, enhancing recorded tests for stability, or configuring test plans for multi-configuration replay. Based on WWDC 2025-344 "Record, replay, and review". |
| license | MIT |
| metadata | {"version":"1.0.0"} |
Recording UI Automation (Xcode 26+)
Guide to Xcode 26's Recording UI Automation feature for creating UI tests through user interaction recording.
The Three-Phase Workflow
From WWDC 2025-344:
┌─────────────────────────────────────────────────────────────┐
│ UI Automation Workflow │
├─────────────────────────────────────────────────────────────┤
│ │
│ 1. RECORD ──────► Interact with app in Simulator │
│ Xcode captures as Swift test code │
│ │
│ 2. REPLAY ──────► Run across devices, languages, configs │
│ Using test plans for multi-config │
│ │
│ 3. REVIEW ──────► Watch video recordings in test report │
│ Analyze failures with screenshots │
│ │
└─────────────────────────────────────────────────────────────┘
Phase 1: Recording
Starting a Recording
- Open your UI test file in Xcode
- Place cursor inside a test method
- Debug → Record UI Automation (or use the record button)
- App launches in Simulator
- Perform interactions - Xcode generates code
- Stop recording when done
What Gets Recorded
- Taps on buttons, cells, controls
- Text input into text fields
- Swipes and scrolling
- Gestures (pinch, rotate)
- Hardware button presses (Home, volume)
Generated Code Example
func testLoginFlow() {
let app = XCUIApplication()
app.launch()
app.textFields["Email"].tap()
app.textFields["Email"].typeText("user@example.com")
app.secureTextFields["Password"].tap()
app.secureTextFields["Password"].typeText("password123")
app.buttons["Login"].tap()
}
Enhancing Recorded Code
Critical: Recorded code is often fragile. Always enhance it for stability.
1. Add Accessibility Identifiers
Recorded code uses labels which break with localization:
app.buttons["Login"].tap()
app.buttons["loginButton"].tap()
Add identifiers in your app code:
Button("Login") { ... }
.accessibilityIdentifier("loginButton")
loginButton.accessibilityIdentifier = "loginButton"
2. Add waitForExistence
Recorded code assumes elements exist immediately:
app.buttons["Login"].tap()
let loginButton = app.buttons["loginButton"]
XCTAssertTrue(loginButton.waitForExistence(timeout: 5))
loginButton.tap()
3. Add Assertions
Recorded code just performs actions without verification:
app.buttons["Login"].tap()
app.buttons["loginButton"].tap()
let welcomeLabel = app.staticTexts["welcomeLabel"]
XCTAssertTrue(welcomeLabel.waitForExistence(timeout: 10),
"Welcome screen should appear after login")
4. Use Shorter Queries
Recorded code may have overly specific queries:
app.tables.cells.element(boundBy: 0).buttons["Action"].tap()
app.buttons["actionButton"].tap()
Query Selection Guidelines
From WWDC 2025-344:
| Scenario | Problem | Solution |
|---|
| Localized strings | "Login" changes by language | Use accessibilityIdentifier |
| Deeply nested views | Long query chains break easily | Use shortest possible query |
| Dynamic content | Cell content changes | Use identifier or generic query |
| Multiple matches | Query returns many elements | Add unique identifier |
Best Practices
- Prefer identifiers over labels
- Use the shortest query that works
- Avoid index-based queries (
element(boundBy: 0))
- Add identifiers to dynamic content
Phase 2: Replay with Test Plans
Test plans allow running the same tests across multiple configurations.
Creating a Test Plan
- File → New → File → Test Plan
- Add test targets
- Configure configurations
Test Plan Structure
{
"configurations": [
{
"name": "iPhone - English",
"options": {
"targetForVariableExpansion": {
"containerPath": "container:MyApp.xcodeproj",
"identifier": "MyApp"
},
"language": "en",
"region": "US"
}
},
{
"name": "iPhone - Spanish",
"options": {
"language": "es",
"region": "ES"
}
},
{
"name": "iPhone - Dark Mode"
Configuration Options
| Option | Purpose |
|---|
language | Test localization |
region | Test regional formatting |
userInterfaceStyle | Test dark/light mode |
targetForVariableExpansion | App target for configuration |
testTimeoutsEnabled | Enable timeout enforcement |
defaultTestExecutionTimeAllowance | Timeout in seconds |
Running with Test Plan
xcodebuild test \
-scheme "MyApp" \
-testPlan "MyTestPlan" \
-destination "platform=iOS Simulator,name=iPhone 16" \
-resultBundlePath /tmp/results.xcresult
Phase 3: Review
Test Report Features
After tests complete:
- View test results in Report Navigator
- Watch video recordings of each test
- See screenshots at failure points
- Analyze timeline of actions
Enabling Attachments
In test plan or scheme:
"options": {
"systemAttachmentLifetime": "keepAlways",
"userAttachmentLifetime": "keepAlways"
}
Capturing Custom Screenshots
func testCheckout() {
let screenshot = app.screenshot()
let attachment = XCTAttachment(screenshot: screenshot)
attachment.name = "Checkout Confirmation"
attachment.lifetime = .keepAlways
add(attachment)
}
Common Patterns
Login Flow Template
func testLoginWithValidCredentials() throws {
let app = XCUIApplication()
app.launch()
let showLoginButton = app.buttons["showLoginButton"]
XCTAssertTrue(showLoginButton.waitForExistence(timeout: 5))
showLoginButton.tap()
let emailField = app.textFields["emailTextField"]
XCTAssertTrue(emailField.waitForExistence(timeout: 5))
emailField.tap()
emailField.typeText("test@example.com")
let passwordField = app.secureTextFields["passwordTextField"]
passwordField.tap()
passwordField.typeText("password123")
app.buttons["loginButton"].tap()
let welcomeScreen = app.staticTexts["welcomeLabel"]
XCTAssertTrue(welcomeScreen.waitForExistence(timeout: 10))
}
Navigation Flow Template
func testNavigateToSettings() throws {
let app = XCUIApplication()
app.launch()
app.tabBars.buttons["Settings"].tap()
let settingsTitle = app.navigationBars["Settings"]
XCTAssertTrue(settingsTitle.waitForExistence(timeout: 5))
app.tables.cells["Account"].tap()
XCTAssertTrue(app.navigationBars["Account"].exists)
}
Form Validation Template
func testFormValidation() throws {
let app = XCUIApplication()
app.launch()
app.buttons["submitButton"].tap()
let errorAlert = app.alerts["Error"]
XCTAssertTrue(errorAlert.waitForExistence(timeout: 5))
XCTAssertTrue(errorAlert.staticTexts["Please fill all fields"].exists)
errorAlert.buttons["OK"].tap()
}
Troubleshooting
Recording Doesn't Start
- Ensure you're in a test method
- Check simulator is available
- Verify app builds and runs
- Try restarting Xcode
Recorded Code Doesn't Work
- Add waitForExistence before interactions
- Check accessibility identifiers are set
- Simplify queries to shortest form
- Run app manually to verify flow works
Tests Pass Locally, Fail in CI
- Increase timeouts for slower CI machines
- Add explicit waits for animations
- Check simulator configuration matches
- Disable animations in test setup:
app.launchArguments = ["--disable-animations"]
Anti-Patterns
Don't Use Raw Recorded Code in CI
app.buttons["Login"].tap()
app.textFields["Email"].typeText("user@example.com")
let loginButton = app.buttons["loginButton"]
XCTAssertTrue(loginButton.waitForExistence(timeout: 10))
loginButton.tap()
Don't Hardcode Coordinates
app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
app.buttons["centerButton"].tap()
Don't Skip Assertions
app.buttons["Login"].tap()
sleep(2)
app.buttons["loginButton"].tap()
XCTAssertTrue(app.staticTexts["Welcome"].waitForExistence(timeout: 10))
Resources
WWDC: 2025-344, 2024-10206, 2019-413
Docs: /xcode/testing/recording-ui-tests, /xctest/xcuiapplication
Skills: ios-xctest-automation, ios-ui-testing