XCUITest Automation: Page Components 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.
Functional grouping with page components
In the previous article, we showed how POM design patterns can be applied to implementing automated interactions with iOS applications through the XCUITest framework. Modeling each view with a single comprehensive page class is adequate for simple views, but factoring the features of more complex view into functional grouping can simplify interactions with these features.
Modeling an application based solely on its views produces a very flat model. It’s quite common for a view to contain groups of elements that are logically associated (e.g. - billing address on an order information view). It’s also common to encounter views with multiple occurrences of an element grouping (e.g. - item tiles on a search results view). Factoring these grouping out into page components can greatly enrich your models, presenting a conceptual framework that automation developers will recognize.
In the following example, the title block elements of the product details view are modeled in a page component:
ProductDetailsView excerpt
//
// ProductView.swift
// Copyright © 2024 REI. All rights reserved.
//
import XCTest
class ProductDetailsView<T: BaseView>: NavigationHub {
lazy var titleBlock: ProductTitleBlock = {
return ProductTitleBlock(self)
}()
// -._.-'¯'-._.-'¯'-._.-'¯'-._.-'¯'-._.-'¯'-._.-'¯'-._.-'¯'-
}
ProductTitleBlock
//
// ProductTitleBlock.swift
// Copyright © 2024 REI. All rights reserved.
//
import XCTest
class ProductTitleBlock<T: BaseView> {
enum Element {
case product_brand
case product_title
case style_or_sku
func locator(_ context: XCUIElement) -> XCUIElement {
switch self {
case .product_brand:
return context.staticTexts["TitleBlock.productBrand.label"]
case .product_title:
return context.staticTexts["TitleBlock.productName.label"]
case .style_or_sku:
return context.staticTexts["TitleBlock.styleOrSku.label"]
}
}
}
private let parent: T
init(_ parent: T) {
self.parent = parent
}
func getProductId() -> String {
return Element.style_or_sku.locator(parent.APP).label
}
}
In the preceding code, note how the ProductTitleBlock page component is defined as a lazy-initialize property of the main ProductDetailsPage class. The page component itself declares a locator enumeration that defined three title block elements, with the getProductId()
function providing access to the corresponding content.
Note that the context used to locate the product ID element is derived from the component’s parent (the product details view). In this case, the search context for component elements is the target application, because there’s no specific container that groups the title block elements. However, it’s common for components to represent specific sub-contexts - a scroll view for a multi-item collection or a cell that contains one of these items. More on search contexts and component collections later.
The test class shown below demonstrates an how an automated test might use this page model to verify that the expected product ID is shown in the title block of the product details view.
class MSAProductTabTests: XCTestCase {
func testSKUShownOnPDP_NoColor_NoSize() {
let sku = "510-145-0012"
guard nil != ascentHomeView.launchTheApplication() else { return }
guard let productDetailsView = ascentHomeView.searchBar.searchForSingleItem(sku) else { return }
XCTAssertEqual(productDetailsView.titleBlock.getProductId(), sku, "Did not find product SKU: \(sku)")
}
}
As shown above, access to the product ID is provided by the ProductTitleBlock page component via the [titleBlock] property of the ProductDetailsView object. The logical grouping provided by this structure helps to differentiate the features of this group relative to other components of the view. This can be extremely helpful when implementing tests that interact with complex views comprised of dozens of elements. Each component provides focused access to a small set of logically associated elements, while a flat model would inundate the test author with an unstructured collection of every element of the entire view.
Page component with unique search context
The ProductTitleBlock page component model shown above illustrates a “virtual component” - a page component with no associated container element. These sorts of components are great for organizing and logically segmenting the functionality of complex views. The presence of a unique container element that encapsulated the features of a page component can increase the cohesion, safety, and efficiency of your model by providing a limited scope for element selection - the search context.
The following example demonstrates how to define and use a pre component search context:
Defining search context for singleton page component
//
// ProductInventoryBlockSubView.swift
// MSAMobileApp-UITests
//
// Created by John Comstock on 8/2/21.
// Copyright © 2021 REI. All rights reserved.
//
import XCTest
class ProductAvailabilityHeader<T: NavigationHub> {
enum Element {
case view_context
case onhand_value
func locator(_ context: XCUIElement) -> XCUIElement {
switch self {
case .view_context:
return context.otherElements["AvailabilityHeader.container.element"]
case .onhand_value:
return context.staticTexts["OnHand.availability.value"]
}
}
}
private let parent: T
private let context: XCUIElement
init(_ parent: T) {
self.parent = parent
self.context = Element.view_context.locator(parent.APP)
}
func getNearbyQuantity() -> String {
return Element.nearby_value.locator(context).label
}
}
Note that the initializer receives a reference to the page or component that’s including the ProductAvailabilityHeader component - the parent. The initializer derives the component’s search context from the parent, and this becomes the context for element searches within the component itself. For example, the getNearbyQuantity()
function derives the locator to the nearby store quantity element relative to this context.
It’s not uncommon for page components to be initialized with a pre-assembled search context. However, this distributes the responsibility for defining and handling the attributes and behaviors of the component to two different classes - the page component and its parent. Prefer confining all component-specific operations to the page component class itself. This increases cohesion and simplifies ongoing maintenance of the page model implementation.
Search context for page component collection entries
Search contexts aren’t required to be unique. It’s commonplace to encounter multiple inherently indistinguishable container elements that encapsulate functionally equivalent components (e.g. - search result items). When modeling these sorts of components, definitive identification of each component must typically derive from attributes of elements within the component. Ideally, however, each container element will provided a unique identifier. In the following example, the style identifier for the product presented in each search result component is stored in the [value] property of the containing cell element:
Defining unique search contexts for page component collection entries
//
// SeaarchResult.swift
// Copyright © 2024 REI. All rights reserved.
//
import XCTest
class SearchResult<T: BaseView> {
enum Element {
case result_cell(String? = nil)
case product_brand
func locator(_ context: XCUIElement) -> XCUIElement {
switch self {
case .result_cell(let id):
if let styleId = id {
let predicate = NSPredicate(format: "identifier == %@ AND value == %@", SearchResultConstants.RESULT_CELL, styleId)
return context.cells.matching(predicate).element
}
return context.cells[SearchResultConstants.RESULT_CELL]
case .product_brand:
return context.staticTexts["SearchResultCell.productBrandLabel"]
}
}
}
private let parent: T
private let styleId: String
private let context: XCUIElement
init?(_ context: XCUIElement, in parent: T) {
guard let styleId = context.value as? String else {
XCTFail("Failed extracting style ID from search result cell value")
return nil
}
self.parent = parent
self.styleId = styleId
self.context = Element.result_cell(styleId).locator(parent.APP)
}
func getProductBrand() -> String {
return Element.product_brand.locator(context).label
}
static func buildResultsList(_ parent: T) -> [SearchResult<T>]? {
let selector = Element.result_cell().locator(parent.APP)
guard selector.waitForExistence(timeout: Constants.standardTimeout) else {
XCTFail("Failed to load Search Results")
return nil
}
var searchResults: [SearchResult<T>] = []
for element in selector.allElementsBoundByAccessibilityElement {
guard let searchResult = SearchResult(element, in: parent) else {
return nil
}
searchResults.append(searchResult)
}
return searchResults
}
}
struct SearchResultConstants {
static let RESULT_CELL = "SearchResult.cell"
}
The SearchResult class provides a static function that builds a collection of search result page components. Note how the buildResultsList()
function employs the no-argument form of the result_cell constant to acquire a list of every search result container element in the view. The implementation iterates over the selected container elements via their accessibility identifiers, which are somewhat more durable than the references produced by allElementsBoundByIndex
. However, these references are still at risk of going stale when the view gets repopulated after the search results are scrolled or filters are applied.
To avoid subsequent reference mismatch issues, the SearchResult initializer produces a fully qualified reference by specifying the style ID as the argument for the result_cell element locator constant. This ensures that we always select the search result we expect. If the specified element exists, the associated search result item is guaranteed to show the expected product. If the specified element isn’t found later (e.g. - excluded by an applied filter), we can be certain that the corresponding search result item is actually gone. Note that the initializer is specified as optional; if the style ID can’t be acquired with the specified element reference, the initializer returns nil
. (This is academic, though, since the initializer also registers a test failure.)
Summary
Page object design patterns provided many benefits when applied to user interface automation of iOS applications. Modeling related groups of elements as page components can make your designs even more cohesive, comprehensible, and flexible. The logical encapsulation provided by page components can reduce the complexity of implementing and interacting with the associated application features. This is especially true for views that contain multiple instances of the same component.
Written with StackEdit.