Use when writing UI tests, recording interactions, tests have race conditions, timing dependencies, inconsistent pass/fail behavior, or XCTest UI tests are flaky - covers Recording UI Automation (WWDC 2025), condition-based waiting, network conditioning, multi-factor testing, crash debugging, and accessibility-first testing patterns
Inherits all available tools
Additional assets for this skill
This skill inherits all available tools. When active, it can use any tool Claude has access to.
Wait for conditions, not arbitrary timeouts. Core principle Flaky tests come from guessing how long operations take. Condition-based waiting eliminates race conditions.
NEW in WWDC 2025: Recording UI Automation allows you to record interactions, replay across devices/languages, and review video recordings of test runs.
These are real questions developers ask that this skill is designed to answer:
→ The skill shows condition-based waiting patterns that work across devices/speeds, eliminating CI timing differences
→ The skill demonstrates waitForExistence, XCTestExpectation, and polling patterns for data loads, network requests, and animations
→ The skill covers Video Debugging workflows to analyze recordings and find the exact step where tests fail
→ The skill explains multi-factor testing strategies and device-independent predicates for robust cross-device testing
→ The skill provides condition-based waiting templates, accessibility-first patterns, and the decision tree for reliable test architecture
If you see ANY of these, suspect timing issues:
sleep() or Thread.sleep() (arbitrary delays)Test failing?
├─ Element not found?
│ └─ Use waitForExistence(timeout:) not sleep()
├─ Passes locally, fails CI?
│ └─ Replace sleep() with condition polling
├─ Animation causing issues?
│ └─ Wait for animation completion, don't disable
└─ Network request timing?
└─ Use XCTestExpectation or waitForExistence
❌ WRONG (Arbitrary Timeout):
func testButtonAppears() {
app.buttons["Login"].tap()
sleep(2) // ❌ Guessing it takes 2 seconds
XCTAssertTrue(app.buttons["Dashboard"].exists)
}
✅ CORRECT (Wait for Condition):
func testButtonAppears() {
app.buttons["Login"].tap()
let dashboard = app.buttons["Dashboard"]
XCTAssertTrue(dashboard.waitForExistence(timeout: 5))
}
// Wait for element to appear
func waitForElement(_ element: XCUIElement, timeout: TimeInterval = 5) -> Bool {
return element.waitForExistence(timeout: timeout)
}
// Usage
XCTAssertTrue(waitForElement(app.buttons["Submit"]))
func waitForElementToDisappear(_ element: XCUIElement, timeout: TimeInterval = 5) -> Bool {
let predicate = NSPredicate(format: "exists == false")
let expectation = XCTNSPredicateExpectation(predicate: predicate, object: element)
let result = XCTWaiter().wait(for: [expectation], timeout: timeout)
return result == .completed
}
// Usage
XCTAssertTrue(waitForElementToDisappear(app.activityIndicators["Loading"]))
func waitForButton(_ button: XCUIElement, toBeEnabled enabled: Bool, timeout: TimeInterval = 5) -> Bool {
let predicate = NSPredicate(format: "isEnabled == %@", NSNumber(value: enabled))
let expectation = XCTNSPredicateExpectation(predicate: predicate, object: button)
let result = XCTWaiter().wait(for: [expectation], timeout: timeout)
return result == .completed
}
// Usage
let submitButton = app.buttons["Submit"]
XCTAssertTrue(waitForButton(submitButton, toBeEnabled: true))
submitButton.tap()
Set in app:
Button("Submit") {
// action
}
.accessibilityIdentifier("submitButton")
Use in tests:
func testSubmitButton() {
let submitButton = app.buttons["submitButton"] // Uses identifier, not label
XCTAssertTrue(submitButton.waitForExistence(timeout: 5))
submitButton.tap()
}
Why: Accessibility identifiers don't change with localization, remain stable across UI updates.
func testDataLoads() {
app.buttons["Refresh"].tap()
// Wait for loading indicator to disappear
let loadingIndicator = app.activityIndicators["Loading"]
XCTAssertTrue(waitForElementToDisappear(loadingIndicator, timeout: 10))
// Now verify data loaded
XCTAssertTrue(app.cells.count > 0)
}
func testAnimatedTransition() {
app.buttons["Next"].tap()
// Wait for destination view to appear
let destinationView = app.otherElements["DestinationView"]
XCTAssertTrue(destinationView.waitForExistence(timeout: 2))
// Optional: Wait a bit more for animation to settle
// Only if absolutely necessary
RunLoop.current.run(until: Date(timeIntervalSinceNow: 0.3))
}
waitForExistence() not sleep()func testExample() {
let app = XCUIApplication()
app.launchArguments = ["UI-Testing"]
app.launch()
}
In app code:
if ProcessInfo.processInfo.arguments.contains("UI-Testing") {
// Use mock data, skip onboarding, etc.
}
override func setUpWithError() throws {
continueAfterFailure = false // Stop on first failure
}
func testExample() {
// Take screenshot on failure
addUIInterruptionMonitor(withDescription: "Alert") { alert in
alert.buttons["OK"].tap()
return true
}
// Print element hierarchy
print(app.debugDescription)
}
sleep(5) // ❌ Wastes time if operation completes in 1s
app.buttons["Next"].tap()
XCTAssertTrue(app.buttons["Back"].exists) // ❌ May fail during animation
app.buttons["Submit"].tap() // ❌ Breaks with localization
// ❌ Test 2 assumes Test 1 ran first
func test1_Login() { /* ... */ }
func test2_ViewDashboard() { /* assumes logged in */ }
element.waitForExistence(timeout: 100) // ❌ Too long
element.waitForExistence(timeout: 0.1) // ❌ Too short
Use appropriate timeouts:
Before (using sleep()):
After (condition-based waiting):
Key insight Tests finish faster AND are more reliable when waiting for actual conditions instead of guessing times.
NEW in Xcode 26: Record, replay, and review UI automation tests with video recordings.
Three Phases:
Supported Platforms: iOS, iPadOS, macOS, watchOS, tvOS, visionOS (Designed for iPad)
Key Principles:
Actions include:
Critical Understanding: Accessibility provides information directly to UI automation.
What accessibility sees:
Best Practice: Great accessibility experience = great UI automation experience.
SwiftUI:
Button("Submit") {
// action
}
.accessibilityIdentifier("submitButton")
// Make identifiers specific to instance
List(landmarks) { landmark in
LandmarkRow(landmark)
.accessibilityIdentifier("landmark-\(landmark.id)")
}
UIKit:
let button = UIButton()
button.accessibilityIdentifier = "submitButton"
// Use index for table cells
cell.accessibilityIdentifier = "cell-\(indexPath.row)"
Good identifiers are:
Why identifiers matter:
Pro Tip: Use Xcode coding assistant to add identifiers:
Prompt: "Add accessibility identifiers to the relevant parts of this view"
Launch Accessibility Inspector:
Features:
What to check:
Sample Code Reference: Delivering an exceptional accessibility experience
Result: New UI test folder with template tests added to project.
During Recording:
Stopping Recording:
func testCreateAustralianCollection() {
let app = XCUIApplication()
app.launch()
// Tap "Collections" tab (recorded automatically)
app.tabBars.buttons["Collections"].tap()
// Tap "+" to add new collection
app.navigationBars.buttons["Add"].tap()
// Tap "Edit" button
app.buttons["Edit"].tap()
// Type collection name
app.textFields.firstMatch.tap()
app.textFields.firstMatch.typeText("Max's Australian Adventure")
// Tap "Edit Landmarks"
app.buttons["Edit Landmarks"].tap()
// Add landmarks
app.tables.cells.containing(.staticText, identifier:"Great Barrier Reef").buttons["Add"].tap()
app.tables.cells.containing(.staticText, identifier:"Uluru").buttons["Add"].tap()
// Tap checkmark to save
app.navigationBars.buttons["Done"].tap()
}
After recording, review and adjust queries:
Multiple Options: Each line has dropdown showing alternative ways to address element.
Selection Recommendations:
Example:
// Recorded options for text field:
app.textFields["Collection Name"] // ❌ Breaks if label localizes
app.textFields["collectionNameField"] // ✅ Uses identifier
app.textFields.element(boundBy: 0) // ✅ Position-based
app.textFields.firstMatch // ✅ Generic, shortest
Choose shortest, most stable query for your needs.
After recording, add assertions to verify expected behavior:
// Validate collection created
let collection = app.buttons["Max's Australian Adventure"]
XCTAssertTrue(collection.waitForExistence(timeout: 5))
// Wait for button to become enabled
let submitButton = app.buttons["Submit"]
XCTAssertTrue(submitButton.wait(for: .enabled, toEqual: true, timeout: 5))
// Fail test if element doesn't appear
let landmark = app.staticTexts["Great Barrier Reef"]
XCTAssertTrue(landmark.waitForExistence(timeout: 5), "Landmark should appear in collection")
override func setUpWithError() throws {
let app = XCUIApplication()
// Set device orientation
XCUIDevice.shared.orientation = .landscapeLeft
// Set appearance mode
app.launchArguments += ["-UIUserInterfaceStyle", "dark"]
// Simulate location
let location = XCUILocation(location: CLLocation(latitude: 37.7749, longitude: -122.4194))
app.launchArguments += ["-SimulatedLocation", location.description]
app.launch()
}
func testWithMockData() {
let app = XCUIApplication()
// Pass arguments to app
app.launchArguments = ["-UI-Testing", "-UseMockData"]
// Set environment variables
app.launchEnvironment = ["API_URL": "https://mock.api.com"]
app.launch()
}
In app code:
if ProcessInfo.processInfo.arguments.contains("-UI-Testing") {
// Use mock data, skip onboarding
}
// Open app to specific URL
let app = XCUIApplication()
app.open(URL(string: "myapp://landmark/123")!)
// Open URL with system default app (global version)
XCUIApplication.open(URL(string: "https://example.com")!)
func testAccessibility() throws {
let app = XCUIApplication()
app.launch()
// Perform accessibility audit
try app.performAccessibilityAudit()
}
Reference: Perform accessibility audits for your app — WWDC23
Test Plans let you:
Configurations:
├─ English
├─ German (longer strings)
├─ Arabic (right-to-left)
└─ Hebrew (right-to-left)
Each locale = separate configuration in test plan.
Settings:
In Configurations tab:
Defaults: Videos/screenshots kept only for failing runs (for review).
"On, and keep all" use cases:
Reference: Author fast and reliable tests for Xcode Cloud — WWDC22
Xcode Cloud = built-in service for:
Workflow configuration:
Viewing Results:
Team Access: Entire team can see run history and download results/videos.
Reference: Create practical workflows in Xcode Cloud — WWDC23
Features:
At moment of failure:
Workflow:
Example:
// Test expected:
let button = app.buttons["Max's Australian Adventure"]
// But overlay shows it's actually text, not button:
let text = app.staticTexts["Max's Australian Adventure"] // ✅ Correct
Click test diamond → Select configuration (e.g., Arabic) → Watch automation run in right-to-left layout.
Validates: Same automation works across languages/layouts.
Reference: Fix failures faster with Xcode test reports — WWDC23
UI tests can pass on fast networks but fail on 3G/LTE. Network Link Conditioner simulates real-world network conditions to catch timing-sensitive crashes.
Critical scenarios:
Install Network Link Conditioner:
sudo open Network\ Link\ Conditioner.pkgVerify Installation:
# Check if installed
ls ~/Library/Application\ Support/Network\ Link\ Conditioner/
Enable in Tests:
override func setUpWithError() throws {
let app = XCUIApplication()
// Launch with network conditioning argument
app.launchArguments = ["-com.apple.CoreSimulator.CoreSimulatorService", "-networkShaping"]
app.launch()
}
3G Profile (most failures occur here):
override func setUpWithError() throws {
let app = XCUIApplication()
// Simulate 3G (type in launch arguments)
app.launchEnvironment = [
"SIMULATOR_UDID": ProcessInfo.processInfo.environment["SIMULATOR_UDID"] ?? "",
"NETWORK_PROFILE": "3G"
]
app.launch()
}
Manual Network Conditioning (macOS System Preferences):
❌ Without Network Conditioning:
func testPhotoUpload() {
app.buttons["Upload Photo"].tap()
// Passes locally (fast network)
XCTAssertTrue(app.staticTexts["Upload complete"].waitForExistence(timeout: 5))
}
// ✅ Passes locally, ❌ FAILS on 3G with timeout
✅ With Network Conditioning:
func testPhotoUploadOn3G() {
let app = XCUIApplication()
// Network Link Conditioner running (3G profile)
app.launch()
app.buttons["Upload Photo"].tap()
// Increase timeout for 3G
XCTAssertTrue(app.staticTexts["Upload complete"].waitForExistence(timeout: 30))
// Verify no crash occurred
XCTAssertFalse(app.alerts.element.exists, "App should not crash on 3G")
}
Key differences:
Tests can pass on device A but fail on device B due to layout differences + network delays. Multi-factor testing catches these combinations.
Common failure patterns:
Create Test Plan in Xcode:
Example Configuration Matrix:
Configurations:
├─ iPhone 14 Pro + LTE
├─ iPhone 14 Pro + 3G
├─ iPad Pro 12.9 + LTE
├─ iPad Pro 12.9 + 3G (⚠️ Most failures here)
└─ iPhone 12 + 3G (⚠️ Older device)
In Test Plan UI:
import XCTest
final class MultiFactorUITests: XCTestCase {
var deviceModel: String { UIDevice.current.model }
override func setUpWithError() throws {
let app = XCUIApplication()
app.launch()
// Adjust timeouts based on device
switch deviceModel {
case "iPad" where UIScreen.main.bounds.width > 1000:
// iPad Pro - larger layout, slower rendering
app.launchEnvironment["TEST_TIMEOUT"] = "30"
case "iPhone":
// iPhone - compact, standard timeout
app.launchEnvironment["TEST_TIMEOUT"] = "10"
default:
app.launchEnvironment["TEST_TIMEOUT"] = "15"
}
}
func testListLoadingAcrossDevices() {
let app = XCUIApplication()
let timeout = Double(app.launchEnvironment["TEST_TIMEOUT"] ?? "10") ?? 10
app.buttons["Refresh"].tap()
// Wait for list to load (timeout varies by device)
XCTAssertTrue(
app.tables.cells.count > 0,
"List should load on \(deviceModel)"
)
// Verify no crashes
XCTAssertFalse(app.alerts.element.exists)
}
}
Scenario: App works on iPhone 14, crashes on iPad Pro over 3G.
Why it crashes:
Test that catches it:
func testLargeLayoutOn3G() {
let app = XCUIApplication()
// Running with Network Link Conditioner on 3G profile
app.launch()
// iPad Pro: Large grid of images
app.buttons["Browse"].tap()
// Wait longer for images on slow network
let firstImage = app.images["photoGrid-0"]
XCTAssertTrue(
firstImage.waitForExistence(timeout: 20),
"First image must load on slow network"
)
// Verify grid loaded without crash
let loadedCount = app.images.matching(identifier: NSPredicate(format: "identifier BEGINSWITH 'photoGrid'")).count
XCTAssertGreater(loadedCount, 5, "Multiple images should load on 3G")
// No alerts (no crashes)
XCTAssertFalse(app.alerts.element.exists, "App should not crash on large device + slow network")
}
In GitHub Actions or Xcode Cloud:
- name: Run tests across devices
run: |
xcodebuild -scheme MyApp \
-testPlan MultiDeviceTestPlan \
test
Test Plan runs on:
Result: Catch device-specific crashes before App Store submission.
UI tests sometimes reveal crashes that don't happen in manual testing. Key insight Automated tests run faster, interact with app differently, and can expose concurrency/timing bugs.
When crashes happen:
Signs in test output:
Failing test: testPhotoUpload
Error: The app crashed while responding to a UI event
App died from an uncaught exception
Stack trace: [EXC_BAD_ACCESS in PhotoViewController]
Video shows: App visibly crashes (black screen, immediate termination).
Enable detailed logging:
override func setUpWithError() throws {
let app = XCUIApplication()
// Enable all logging
app.launchEnvironment = [
"OS_ACTIVITY_MODE": "debug",
"DYLD_PRINT_STATISTICS": "1"
]
// Enable test diagnostics
if #available(iOS 17, *) {
let options = XCUIApplicationLaunchOptions()
options.captureRawLogs = true
app.launch(options)
} else {
app.launch()
}
}
func testReproduceCrash() {
let app = XCUIApplication()
app.launch()
// Run exact sequence that causes crash
app.buttons["Browse"].tap()
app.buttons["Photo Album"].tap()
app.buttons["Select All"].tap()
app.buttons["Upload"].tap()
// Should crash here
let uploadButton = app.buttons["Upload"]
XCTAssertFalse(uploadButton.exists, "App crashed (expected)")
// Don't assert - just let it crash and read logs
}
Run test with Console logs visible:
Locations:
Look for:
Example crash log:
Exception Type: EXC_BAD_ACCESS (SIGSEGV)
Exception Codes: KERN_INVALID_ADDRESS at 0x0000000000000000
Thread 0 Crashed:
0 MyApp 0x0001a234 -[PhotoViewController reloadPhotos:] + 234
1 MyApp 0x0001a123 -[PhotoViewController viewDidLoad] + 180
This tells us:
PhotoViewController.reloadPhotos(_:)viewDidLoadMost UI test crashes are concurrency bugs (not specific to UI testing). Reference related skills:
// Common pattern: Race condition in async image loading
class PhotoViewController: UIViewController {
var photos: [Photo] = []
override func viewDidLoad() {
super.viewDidLoad()
// ❌ WRONG: Accessing photos array from multiple threads
Task {
let newPhotos = await fetchPhotos()
self.photos = newPhotos // May crash if main thread access
reloadPhotos() // ❌ Crash here
}
}
}
// ✅ CORRECT: Ensure main thread
class PhotoViewController: UIViewController {
@MainActor
var photos: [Photo] = []
override func viewDidLoad() {
super.viewDidLoad()
Task {
let newPhotos = await fetchPhotos()
await MainActor.run { [weak self] in
self?.photos = newPhotos
self?.reloadPhotos() // ✅ Safe
}
}
}
}
For deep crash analysis: See swift-concurrency skill for @MainActor patterns and memory-debugging skill for thread-safety issues.
After fixing:
func testPhotosLoadWithoutCrash() {
let app = XCUIApplication()
app.launch()
// Rapid fire interactions that previously caused crash
app.buttons["Browse"].tap()
app.buttons["Photo Album"].tap()
// Load should complete without crash
let photoGrid = app.otherElements["photoGrid"]
XCTAssertTrue(photoGrid.waitForExistence(timeout: 10))
// No alerts (no crash dialogs)
XCTAssertFalse(app.alerts.element.exists)
}
func testPhotosLoadUnderStress() {
let app = XCUIApplication()
app.launch()
// Repeat the crash-causing action multiple times
for iteration in 0..<10 {
app.buttons["Browse"].tap()
// Wait for load
let grid = app.otherElements["photoGrid"]
XCTAssertTrue(grid.waitForExistence(timeout: 10), "Iteration \(iteration)")
// Go back
app.navigationBars.buttons["Back"].tap()
app.buttons["Refresh"].tap()
}
// Completed without crash
XCTAssertTrue(true, "Stress test passed")
}
WWDC 2025 Sessions:
WWDC 2023 Sessions:
WWDC 2024 Sessions:
Apple Documentation:
Note: This skill focuses on reliability patterns and Recording UI Automation. For TDD workflow, see superpowers:test-driven-development.
History: See git log for changes