Skip to main content

XCUITest Automation: Encapsulating Element Locators in Swift Enumerations

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.

In user-interface automation, following a page-model approach provides a structured interface that your tests can use to drive the target application and verify expected behaviors. A foundational feature of any user-interface automation strategy is a mechanism to locate and interact with the elements of the interface - buttons, input fields, descriptive text, et cetera.

Apple’s user-interface automation framework for driving iOS application is XCUITest. This framework provides a rich set of classes, properties, and enumerations from which to specify the characteristics of target elements. To produce cohesive models that encapsulate all the details of your element locators, consider Swift enumerations.

Defining element locators in Swift enumerations

Two common issues that complicate the usage and maintenance of any programming interface are duplicate definitions of constant values and incomplete declarations that require additional context to be used. Each of these characteristics presents challenges when the definitions of associated elements in the target application change, because you’re forced to track down all of the related definitions and apply the same revisions everywhere.

To avoid these sorts of issues, declare element locators in a comprehensive Swift enumeration in the view or component class that’s responsible for interacting with the corresponding elements. For example:

enum Element {
    case view_title
    case refine_view(String)
    case facet_label(String? = nil)
    case filter_cell(String? = nil, String? = nil)
    
    func locator(_ context: XCUIElement) -> XCUIElement {
        switch self {
        case .view_title:
            return context.navigationBars[rawValue]
        case .refine_view:
            return context.collectionViews[rawValue]
        case .facet_label(let name):
            if let nameOrKey = name {
                let predicate = NSPredicate(format: "identifier == %@ AND (label == %@ OR value == %@)",
                                            rawValue, nameOrKey, nameOrKey)
                return context.staticTexts.matching(predicate).element
            }
            return context.staticTexts[rawValue]
        case .filter_cell(let facet, let filter):
            if let facetKey = facet, let filterName = filter {
                let cellPredicate = NSPredicate(format: "identifier == %@ AND value == %@", 
                                                rawValue, facetKey)
                if filterName.isEmpty {
                    return context.cells.matching(cellPredicate)
                        .containing(.staticText, identifier: Self.Constants.FILTER_LABEL.rawValue).element
                } else {
                    let labelPredicate = NSPredicate(format: "identifier == %@ AND label BEGINSWITH %@",
                                                     Self.Constants.FILTER_LABEL.rawValue, filterName)
                    return context.cells.matching(cellPredicate).containing(labelPredicate).element
                }
            }
            return context.cells[rawValue]
        }
    }
    
    var rawValue: String {
        switch self {
        case .view_title:
            return "Search Filter"
        case .refine_view(let name):
            return name
        case .facet_label:
            return "RefineSearch.filterFacet.label"
        case .filter_cell:
            return "RefineSearch.filterName.cell"
        }
    }
    
    enum Constants: String {
        case FILTER_LABEL = "RefineSearch.filterName.label"
    }
}

The preceding declaration defines a locator function, a rawValue computed property, a nested Constants enumeration, and four element locator values:

  • A simple locator for an element with a constant identifier…
    • … produces a constant context-relative element locator.
  • A parameterized locator with a single required associated value…
    • … produces context-relative element locators based on the specified value.
  • A parameterized locator with a single optional associated value…
    • … [value omitted] produces a constant context-relative locator that finds multiple matching elements. … OR …
    • … [value defined] produces context-relative locators based on the defined value that find unique elements.
  • A parameterized locator with a pair of optional associated values…
    • … [values omitted] produces a constant context-relative locator that finds multiple matching elements. … OR …
    • … [first defined, second empty] produces context-relative locators based on the defined value that finds multiple matching elements. … OR …
    • … [both defined] produces context-relative locators based on the defined values that find unique elements.

Note that the locator function requires a “context” argument. This is the search context for the returned element locator. The element locator values produced by locator acquire the identifiers they need from the rawValue computed property. Associated values are declared or omitted in each context (locator or rawValue) as needed.

The “context” argument will typically be the “application” object, which will search the entire view hierarchy. To find elements within a sub-context, provide this instead:

func setFilter(_ filter: String, in facet: String, of refinement: String) {
    let refineView = Element.refine_view(refinement).locator(XCUIApplication())
    let filterElem = Element.filter_cell(facet, filter).locator(refineView)
    if !filterElem.isSelected { filterElem.tap() }
}

In the preceding example, the “application” object is the context of the “refine” view locator, which defines the context for the “filter” element locator.

NOTE: For simple cases, localized handling of sub-contexts like the “refine” view does the trick. When modeling more complex sub-contexts with multiple associated elements and related behaviors, the use of component classes can provide cleaner, more maintainable designs. The details of this structure are documented here.

The nested Constants enumeration is used to define constant value that are used multiple time within the Element enumeration, but are not associated with a defined element locator value. This “constants enumeration” pattern ensures that each value is defined only once, avoiding the risk of retaining stale copies when value updates are needed.

Summary

The encapsulation of element locators in comprehensive Swift enumerations can eliminate the confusion and inconsistencies that bedevil other approaches. By defining all aspects of each target element in a single place, the task of debugging and maintaining element locators is greatly simplified. With context scoping and associated values, each locator enumeration constants can provide selectors with graduated levels of specificity - from global all-of-type matching to context-constrained unique-instance matching.


Note regarding another enum-based locator definition strategy

While preparing to write this article, I performed a search to determine if others have described similar strategies. I found this article by Nishith Shah, which begins with the same basic approach I used. As this strategy is developed through the article, a few key differences emerge:

  • While the use of raw values solves the issue of duplicated constant declarations, this choice precludes the use of associated values which greatly enhance the expressiveness of locators for element collections. While these can be modeled via nested components, this level of factoring often results in a proliferation of tiny classes with limited functionality which are difficult to debug and maintain.
    The rawValue computed property demonstrated in the example above also avoids duplicated constant declarations, with the added benefit that the resulting identifiers can incorporate specified associated values.
  • Grouping elements by type and handling them in a generic fashion increases complexity and makes the code harder to maintain. The apparent repetition incurred by handling each case independently eliminates the risk of breaking cases you weren’t intending to change when updating cases that require change.
  • The use of a computed property instead of a function to generate locators precludes the option of providing a search context. While this context is often the application under test, it may also be the root element of a component model (e.g. - an item in a table view). It might also be an entirely different application, such as Safari or Springboard (which is used to access elements in the status bar).

Written with StackEdit.

See All Articles