Skip to main content

XCUITest Automation: Page Object Models for iOS Test Automation

Creating Stable, Maintainable User Interface Test Automation in Swift

When creating test automation to validate the performance and behavior of a complex software application, adhering to solid engineering practices will produce stable, maintainable tests. This is especially true when driving the application through its user interface. Page Object Model (POM) design patterns provide a solid framework to achieve this objective.

Implementing Page Object Model (POM) patterns in Swift

Page Object Model (POM) design patterns, originally conceived to automate web application user interface tests, can be applied to testing iOS application through the XCUITest framework. Following a page-model approach produces structured interfaces that your tests can use to drive the target application and verify expected behaviors.

  • Each application main view is associated with a separate page model class.
  • Functions that trigger navigation wait for the expected landing view to appear and return an instance of the corresponding page model class.
  • To facilitate navigation rewinding, stackable page model classes are parameterized, with the type parameter supplying the return type for back/close operations.
  • Groups of elements that comprise distinct units of application functionality can be modeled as page component classes.
  • Repeated element groupings (e.g. - search result items) should be modeled as page component collections.
  • Define element locators in Swift enumerations to avoid duplication of element types and attributes.

Synchronization - The lynchpin of stable automation

In user interface automation, everything begins and ends with definite synchronization between the application under test (AUT) and the automation that drives it. This isn’t just a figure of speech; it’s literally true.

  • Before interacting with a view that’s just been opened, perform load-completion checks to ensure that the application is ready for action.
  • After performing an action that triggers an application state change (expanding a dropdown, opening a modal, etc.), wait for the expected response.

Adhering to these rules ensures that your automation interacts smoothly and reliably with the application. It also enables you to recognize unexpected behavior at the point where it’s encountered, failing with clear diagnostic output indicating what went wrong. Remember…

RULE #1: Fail Fast with Detailed Diagnostics

The SynchronizedView protocol

In the REI automation framework, all of our application view page models implement a SynchronizedView protocol. The methods that facilitate page load synchronization are:

  • waitForView(required: Bool) -> Self? Wait for the view associated with this model to finish loading.
  • checkViewCriteria() -> String? Determine if all evaluated criteria indicate that the view associated with this model has finished loading.
  • hasNetworkSpinner() -> Bool Determine if transition to the view associated with this model initially displays a network activity spinner.
  • launchTheApplication() -> Self? Launch the target application and wait for the expected view to load.

The foundation of the synchronization provided by the framework is the checkViewCriteria() function. Each page class declares a view-specific implementation of this function. As described in the function header, the implementation returns nil when all evaluated criteria are met. If any evaluated criterion is unmet, the function returns a string that describes the issue. If after twenty seconds the function doesn’t return nil, the affected test is terminated with the description of the unmet criterion.

NOTE: The checkViewCriteria() function should perform instantaneous evaluations; it should check for each defined criterion and return immediately upon encountering one that’s not met. The implementation of waitForView() polls this function repeatedly until it returns nil or the maximum delay interval has elapsed.

Page Object Model Protocol (Part 1 - Synchronization)
//
// SynchronizedView.swift (Part 1)
// Copyright © 2024 REI. All rights reserved.
//

import  XCTest

protocol  SynchronizedView {
    /// Determine if transition to the view associated with this model initially displays a network activity spinner.
    /// > Note: The default implementation of this function returns `false`; declare an override returning `true` if the associated view opens with a spinner.
    /// - Returns: `true` if expecting an initial network spinner
    func  hasNetworkSpinner() -> Bool

    /// Determine if all evaluated criteria indicate that the view associated with this model has finished loading.
    /// - Returns: `nil` if all evaluated criteria are satisfied; otherwise, description of unsatisfied view load criterion
    func  checkViewCriteria() -> String?

    /// Launch the target application and wait for the expected view to load.
    /// - Returns: this view model; `nil` if expected view doesn't load
    func  launchTheApplication() -> Self?

    /// Terminate the target application.
    func  terminateTheApplication()

    /// Wait for the view associated with this model to finish loading.
    /// > Note: If ``hasNetworkSpinner()`` returns `true`, this function will wait for the network spinner to vanish prior to evaluation of view load criteria.
    /// > Note: If [required] is `true` and the standard load interval expires, the current test will fail with a message indicating this model and the description of the unsatified view load criterion.
    /// - Parameter required: [optional] `false` if incomplete loading of the associated view is allowed (default = `true`)
    /// - Returns: this view model; `nil` if view transition fails
    func  waitForView(required: Bool) -> Self?
}

extension  SynchronizedView {
    func  waitForView(required: Bool = true) -> Self? {
        if  hasNetworkSpinner() {
            // TODO: application-dependent spinner check
        }

        if  let result = Utility.waitForExpression(checkViewCriteria(), timeout: Constants.standardTimeout) {
            if  required { Utility.failWithAlertCheck("Failed opening '\(getViewName())' view: \(result)") }
            return  nil
        }
        return  self
    }
}

The navigation strategy employed by the REI automation framework explicitly defines the relationships between associated views:

  • Every method that triggers a view transition returns an instance of the page class for the expected landing view.
  • To facilitate retracing steps in the navigation stack, each page object retains the instance that spawned it (the origin).
  • Each navigation stack originates from a root view, which has no origin.
  • Non-root page classes are parameterized, and the parameter type specified when each instance is created resolves the class of the origin when stepping back down the stack.

The first point of this strategy is the key to effortless stability at view transitions. When your model returns a new page object, the framework has already ensured that the corresponding view is present and prepared for the next action. This is accomplished in openView() and returnToOrigin() via the waitForView() function, which uses the checkViewCriteria() function of the new page object for synchronization.

Page Object Model Protocol (Part 2 - Object Chaining)
//
// SynchronizedView.swift (Part 2)
// Copyright © 2023 REI. All rights reserved.
//

import  XCTest

protocol  SynchronizedView {
    /// Origin of this view.
    /// > Note: The origin is the view that the application returns to when the user taps **Back** or **Close**; may be `nil`
    var origin: BaseView? { get }

    /// Target application for this view.
    var APP: XCUIApplication { get }

    /// Required constructor:
    /// - Parameters
    /// - origin: [optional] Origin of this view (default = `nil`)
    /// - app: Target application for this instance
    /// - See: ``origin``
    init(_  origin: BaseView?, app: XCUIApplication)

    /// Open the indicated view by tapping the specified element.
    /// > Note: If [required] is `true` and the standard load interval expires, the current test will fail with a message indicating this model and the description of the unsatified view load criterion.
    /// - Parameters:
    /// - viewType: Class of the automation model associated with the view that opens after tapping the specified element
    /// - targetApp: [optional] Target application for view being opened (default = `self.APP`)
    /// - element: Element that triggers the expected view transition
    /// - fixed: [optional] `true` if the specified element is non-scrollable (default = `false`)
    /// - required: [optional] `false` if incomplete loading of the associated view is allowed (default = `true`)
    /// - Returns: target view model; `nil` if view transition fails
    /// - See: ``waitForView``
    func  openView<T: BaseView>(_  viewType: T.Type, targetApp: XCUIApplication?, byTapping  element: XCUIElement,
                                fixed: Bool, required: Bool) -> T?

    /// Tap **Back** / **Close** to navigate back to the origin of the current view, as registered in this model.
    /// > Note: This function is unsupported for "navogation hub" models, as these represent the bottom of the nav stack and therefore have no related **Back** action.
    /// - Parameters:
    /// - element: [optional] Element that triggers the expected view transition (default = `nil`)
    /// - fixed: [optional] `true` if the specified element is non-scrollable (default = `false`)
    /// - required: [optional] `false` if incomplete loading of the associated view is allowed (default = `true`)
    /// - Returns: origin view model; `nil` if view transition fails
    /// - See: ``origin``
    func  returnToOrigin<T: BaseView>(byTapping  element: XCUIElement?, fixed: Bool, required: Bool) -> T?
}

Best practices for stable automation models

The REI framework implements several patterns that promote stable, reliable operation of our user-interface test automation. The full benefits of these facilities rely on the proper implementation of protocol functions within the page classes.

  • Use the openView() and returnToOrigin() functions for all view transitions:
      func  openKnowledgeBase() -> KnowledgeBaseHome<SettingsHomeView>? {
          return  openView(KnowledgeBaseHome<SettingsHomeView>.self, byTapping: Element.knowledge_base_link.locator(APP))
      }
    
  • Perform explicit synchronization after each action that triggers a state transition (e.g. - switch filter mode):
      func  switchMode(via  element: Element) {
          let reference = element.locator(APP)
          if !reference.isSelected {
              reference.tap()
              reference.wait(until: \.isSelected)
              guard reference.isSelected else {
                  XCTFail("Filter mode failed to become selected")
                  return
              }
          }
      }
    
  • NOTE: Each of these examples ensures that the transition is complete and the application is in the expected state before returning to the caller. This practice, called exit-state synchronization, is far more efficient and reliable than adding synchronization at the beginning of down-stream functions.

Best practices for test implementation

Adhering to a few core practices affords your tests the greatest benefit from the synchronization and stability provided by the REI framework:

  • Only the initial page object is instantiated directly by the tests, typically in the setupWithError() function.
  • All other page objects are returned by functions that trigger view transitions.
  • Use the guard let pattern to resolve the optional page object values returned by transition-triggering functions.

The example test class shown below demonstrates these practices. Note that the test code doesn’t require explicit verification of landing views at transitions, because the model does this automatically.

import  XCTest

class  MSABasicNavigationTests: XCTestCase {
    private  var ascentHomeView: AscentHomeView!

    override  func  setUpWithError() throws {
        ascentHomeView = AscentHomeView(app: XCUIApplication.testingApp)

        // stop immediately on failure
        continueAfterFailure = false
    }

    override  func  tearDownWithError() throws {
        ascentHomeView = nil
    }

    func  testNavigationToAscentKnowledgeBase() {
        guard  nil != ascentHomeView.launchTheApplication() else { return }
        guard  let settingsHomeView = ascentHomeView.footerBar.selectSettingsTab() else { return }
        guard  let ascentKnowledgeBase = settingsHomeView.openKnowledgeBase() else { return }
        guard  nil != ascentKnowledgeBase.backToOrigin() else { return }
    }
}

The preceding example demonstrates the page object chaining behavior of the framework. The output of each function that triggers a view transition is the page object for the view that was activated by the function. If a nil is returned, the test exits immediately. Because we set continueAfterFailure to false during test setup, this is academic - an error has already been registered explaining the issue. However, it’s still a good practice.

Handling transitions with interstitial “toast”

When an action triggers a view transition that also includes a “toast” message, special handling is required to capture and respond to the “toast” message and synchronize with the newly activated view. These events must be handled within the context of a single function; Attempting to handle the “toast” message separately is far too likely to result in unreliable synchronization. The REI framework provides two functions that encapsulate the handling of transitions with “toast”:

    /// Open the indicated view by tapping the specified element, waiting for an expected "toast" message and performing the action it presents.
    /// > Note: If [required] is `true` and the standard load interval expires, the current test will fail with a message indicating this model and the description of the unsatified view load criterion.
    /// - Parameters:
    /// - viewType: Class of the automation model associated with the view that opens after tapping the specified element
    /// - originType: [optional] Class of landing view model if back/close of target view performs unlinked navigation (default = `nil`)
    /// - element: Element that triggers the expected view transition
    /// - fixed: [optional] `true` if the specified element is non-scrollable (default = `false`)
    /// - required: [optional] `false` if incomplete loading of the associated view is allowed (default = `true`)
    /// - Returns: **TransitionWithToast()** object containing target view model and "toast" information; object with default values if view transition fails or "toast" isn't found
    /// - See: ``waitForView``
    /// - See: ``TransitionWithToast``
    /// - See: ``ToastInfo``
    func  openViewWithToastAction<U: BaseView>(_  viewType: U.Type, originType: BaseView.Type? = nil, byTapping  element: XCUIElement,
                                               fixed: Bool = false, required: Bool = true) -> TransitionWithToast<U>

    /// Tap **Back** / **Close** to navigate back to the origin of the current view, as registered in this model, waiting for an expected "toast" message and performing its action if specified.
    /// > Note: This function is unsupported for "navigation hub" view models, as these represent the bottom of the nav stack and therefore have no related **Back** action.
    /// - Parameters:
    /// - element: Element that triggers the expected view transition
    /// - fixed: [optional] `true` if the specified element is non-scrollable (default = `false`)
    /// - required: [optional] `false` if incomplete loading of the associated view is allowed (default = `true`)
    /// - performAction: [optional] `true` if the action presented by the "toast" message should be performed (default = `false`)
    /// - Returns: **TransitionWithToast()** object containing target view model and "toast" information; object with default values if view transition fails or "toast" isn't found
    /// - See: ``origin``
    /// - See: ``waitForView``
    /// - See: ``TransitionWithToast``
    /// - See: ``ToastInfo``
    func  returnToOriginWithToast(byTapping  element: XCUIElement, fixed: Bool = false,
                                  required: Bool = true, performAction: Bool = false) -> TransitionWithToast<T>
ToastInfo
struct  ToastInfo {
    let message: String
    let identifier: String
    let hasAction: Bool
}
TransitionWithToast
struct  TransitionWithToast<T> {
    let view: T?
    let toastInfo: ToastInfo?

    init(view: T? = nil, toastInfo: ToastInfo? = nil) {
        self.view = view
        self.toastInfo = toastInfo
    }
}

As indicated, the preceding functions handle view transitions that include the appearance of interstitial “toast” messages. The return values of these functions (TransitionWithToast) encapsulate the view model and a “toast” information (ToastInfo) object. These are the potential outcomes:

  • If the expected view activates, a reference to the view model is returned in the [view] property.
  • If the expected view doesn’t activate, the [view] property will be set to nil.
    • NOTE: If the [required] argument of the function call is true (the default value), a test failure will be registered.
  • If a “toast” message appears, a ToastInfo object is returned in the [toastInfo] property containing the message text, identifier, and action.
  • If no “toast” message appears, the [toastInfo] property will be set to nil.
    • NOTE: No test failure is registered.
  • The test itself must determine whether the returned “toast” information (or lack thereof) meets expectations.

The following demonstrates the application of openViewWithToastAction() to tap an element and perform the navigation provided by the “toast” message:

    func addLabelAndOpenPrintBasket() -> TransitionWithToast<PrintBasketView> {
        let optionsSheet = Element.options_sheet.locator(APP)
        let selector = Element.add_label.locator(optionsSheet)
        return openViewWithToastAction(PrintBasketView.self, originType: ToolsHomeView.self,
                                       byTapping: selector, fixed: true)
    }

Note that the “origin” type is specified here. This isn’t always required, but the origin of the Print Basket is the Tools Home view, not the “quick print” menu. Here’s an example of a test that uses this function:

    func testPrintOptions_PrintBasket() {
        let sku = "2028570010"
        guard nil != ascentHomeView.launchTheApplication() else { return }
        guard let productDetailsView = ascentHomeView.searchBar.searchForSingleItem(sku) else { return }
        guard let quickPrintMenu = productDetailsView.tapPrintingOptionsButton() else { return }
        let transition = quickPrintMenu.addLabelAndOpenPrintBasket()
        guard let printBasketView = transition.view else { return }
        XCTAssertEqual(transition.toastInfo?.message, "1 label added Print Basket", "Toast message mismatch")
        XCTAssertEqual(printBasketView.getPrintBasketItemCount(), 1, "Print basket item count mismatch")
        guard nil != printBasketView.backToToolsHome() else { return }
    }

This test opens a product page by searching for a SKU, opens the “quick print” menu, adds a label, and opens the print basket via the “toast” action. Synchronization and verification are performed at every step.

Summary

Applying Page Object Model (POM) patterns to the implementation of XCUITest automation produces well-structured interfaces that are rational and maintainable. Ubiquitous definite synchronization between your tests and the target application ensure reliable, efficient execution. Utilization of framework-supported linked navigation with automatic page-load verification ensures that you always land where you expect - or fail immediately when you don’t. Following the “fail fast with detailed diagnostics” rule eliminates much of the ambiguity that can otherwise make the process of investigating test failures frustrating and time-consuming.

Written with StackEdit.

See All Articles