Back to blog

XCUITest Page Objects: Maintainable iOS UI Testing

·3 min read

UI tests are notoriously fragile. A button moves, a label changes, and suddenly half your tests fail. The Page Object pattern fixes this by encapsulating UI structure in reusable objects.

The Problem

Without Page Objects, tests look like this:

func testLoginFlow() {
    app.buttons["loginButton"].tap()
    app.buttons["submitButton"].tap()
    XCTAssertTrue(app.staticTexts["Welcome"].exists)
    app.buttons["menuButton"].tap()
    // ... more element queries scattered everywhere
}

When the UI changes, you update dozens of tests.

The Solution: Page Objects

Encapsulate each screen in a class:

// HomeScreen.swift
class HomeScreen {
    let app: XCUIApplication
 
    init(app: XCUIApplication) {
        self.app = app
    }
 
    // Elements
    var primaryButton: XCUIElement {
        app.buttons[AccessibilityIdentifiers.Home.primaryButton]
    }
 
    var statusBar: XCUIElement {
        app.otherElements[AccessibilityIdentifiers.Home.statusBar]
    }
 
    // Actions
    func tapPrimaryAction() -> DetailScreen {
        primaryButton.tap()
        return DetailScreen(app: app)
    }
 
    func tapStatusBar() -> DetailScreen {
        statusBar.tap()
        return DetailScreen(app: app)
    }
 
    // Verification
    func verifyOnScreen() {
        XCTAssertTrue(primaryButton.waitForExistence(timeout: 5))
    }
}

Now tests read like user stories:

func testDetailFlow() {
    let homeScreen = HomeScreen(app: app)
    homeScreen.verifyOnScreen()
 
    let detailScreen = homeScreen.tapPrimaryAction()
    detailScreen.verifyOnScreen()
    detailScreen.tapAction()
 
    let settings = detailScreen.openSettings()
    settings.verifyOnScreen()
}

Centralized Accessibility Identifiers

Define identifiers in one place:

// AccessibilityIdentifiers.swift
enum AccessibilityIdentifiers {
    enum Home {
        static let primaryButton = "home.primaryButton"
        static let secondaryButton = "home.secondaryButton"
        static let statusBar = "home.statusBar"
    }
 
    enum Detail {
        static let actionButton = "detail.actionButton"
        static let closeButton = "detail.closeButton"
        static let contentView = "detail.contentView"
    }
 
    enum Settings {
        static let tableView = "settings.tableView"
        static func settingRow(_ id: String) -> String {
            "settings.row.\(id)"
        }
    }
}

Use descriptive, hierarchical identifiers. detail.actionButton is easier to debug than btn1.

Base Test Class

Reduce boilerplate with a base class:

// BaseTest.swift
class BaseTest: XCTestCase {
    var app: XCUIApplication!
 
    override func setUpWithError() throws {
        continueAfterFailure = false
        app = XCUIApplication()
        app.launchArguments = ["--uitesting", "--reset-data"]
        app.launchEnvironment = [
            "UITESTING": "1",
            "ANIMATION_SPEED": "0"
        ]
        app.launch()
    }
 
    func waitForElement(_ element: XCUIElement, timeout: TimeInterval = 5) -> Bool {
        element.waitForExistence(timeout: timeout)
    }
}

Handling Platform Quirks

iOS has some UI testing quirks. Here's how to handle them:

Tab Bar Identifiers

iOS 26's Tab API doesn't propagate accessibility identifiers:

// Workaround: Match by label
var homeTab: XCUIElement {
    app.buttons["Home"]  // Use visible label, not identifier
}

Volume Slider

MPVolumeView doesn't expose proper identifiers:

var volumeSlider: XCUIElement {
    // Fall back to first slider on screen
    app.sliders.firstMatch
}

Custom Gestures

Some views need custom dismiss gestures:

func dismissModal() {
    let start = app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.2))
    let end = app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.9))
    start.press(forDuration: 0.1, thenDragTo: end)
}

Test Organization

Structure tests by user flow:

MyAppUITests/
├── Base/
│   └── BaseTest.swift
├── PageObjects/
│   ├── HomeScreen.swift
│   ├── DetailScreen.swift
│   └── SettingsScreen.swift
├── Helpers/
│   └── AccessibilityIdentifiers.swift
└── Tests/
    ├── NavigationTests.swift
    ├── DetailTests.swift
    └── SettingsTests.swift

Benefits

  1. Maintenance: UI changes affect one Page Object, not all tests
  2. Readability: Tests read like user stories
  3. Reusability: Common actions defined once
  4. Debugging: Clear structure makes failures easier to diagnose

Page Objects add initial setup time, but pay off quickly as your test suite grows.

Full Implementation

Apply this pattern to any iOS app with UI tests.

Share:

Related Posts

Quick tips for setting up comprehensive snapshot testing across devices and color schemes.

How to use SwiftData safely in Swift 6 with strict concurrency using the DataThespian wrapper.

·4 min read

How to define iOS projects as Swift code, with reusable templates and consistent configuration.