Five tips for automating a webview-based iOS app with XCUITest

Recently I was tasked with automating some tests for one of our iOS apps at work. I’m always ready to jump on anything to do with iOS, so I was very keen to complete the project to a very high standard.

The app in question is mostly embedded inside a webview. This means that whenever I want the automation to interact with the app, it would need to dive into the webview to find the elements that it needs - and that isn’t as easy at it sounds.

To help anybody else who may be in a similar situation, but also for me to reference in the future, I decided I would write about some of the things that helped me navigate this difficult task.

The Accessibility Inspector isn’t so useful#

When your app is native, the Accessibility Inspector is a great tool. However, in our case, it isn’t very useful. It is unable to pick out any kind of ID that you could use to identify the element, and is limited to showing you the label, text and the element type. I also found that it doesn’t show the view hierarchy, meaning you will struggle to find how elements are nested within each other.

What really accelerated the development of my tests was to add a method that would print all of the elements on the screen at any desired point during my tests. This gave me a snapshot of what XCUITest could see, displaying elements label or value, should they not be empty. The code that I used is below, though you can modify it to display any element type that you desire.

func printElementsOnScreen() {
    print("----- ELEMENTS ON SCREEN -----")
    print("Buttons: \(app.buttons.allElementsBoundByIndex.map { $0.label.isEmpty ? "Value: \($0.value ?? "")" : "Label: \($0.label)" })")
    print("Static Texts: \(app.staticTexts.allElementsBoundByIndex.map { $0.label.isEmpty ? "Value: \($0.value ?? "")" : "Label: \($0.label)" })")
    print("Text Fields: \(app.textFields.allElementsBoundByIndex.map { $0.label.isEmpty ? "Value: \($0.value ?? "")" : "Label: \($0.label)" })")
    print("Text Views: \(app.textViews.allElementsBoundByIndex.map { $0.label.isEmpty ? "Value: \($0.value ?? "")" : "Label: \($0.label)" })")
}

Create a helper function to wait for your elements to be in a particular state#

As I continued to write my tests, I noticed that I was repeating a lot of the same code. This code consisted of four main parts:

  • Get the element I want to wait for
  • Set my predicate (“exists == true”)
  • Create my element expectation using the element and the predicate
  • Wait for the element to complete, using XCTAssert and XCTWaiter

Putting this code into a reusable function allowed my XCTest methods to be clean and readable, and you can see the implementation and usage below.

enum ElementState { case visible, invisible }

func waitFor(_ element: XCUIElement, toBe state: ElementState, secondsToWait: Double=10) -> Bool {
    let predicate = NSPredicate(format: "exists == \(state == .visible)")
    let elementExpectation = expectation(for: predicate, evaluatedWith: element, handler: nil)
    let result = XCTWaiter().wait(for: [elementExpectation], timeout: secondsToWait)
    return result == .completed
}

// Usage
func test_waitFor() throws {
    let app = XCUIApplication()
    app.launch()

    let helloWorldText = app.staticTexts("Hello World!")
    XCTAssert(
        waitFor(helloWorldText, toBe: .visible, secondsToWait: 10), 
        "Hello World text didn't appear in 10 seconds"
    )
}

You can interact with an element using its index#

If the element you want to interact with doesn’t have a value or a label: don’t fret. As long as you know the type of your element (such as StaticText, Button, TextField), you can get it by its index. This will involve a bit of trial and error, as you will need to try different indexes before you can find your desired element, but it’s a good last resort that may sometimes be required.

func test_getByIndex() throws {
    let app = XCUIApplication()
    app.launch()

    app.buttons.element(boundBy: 0).tap() // first button
    app.buttons.element(boundBy: 5).tap() // sixth button
}

Additionally, if you have more than one element that share the same attribute that you’re using to search, such as its value or label, you can use element(boundby: ) to specify exactly which element to use.

func test_filteredGetByIndex() throws {
    let app = XCUIApplication()
    app.launch()

    app.buttons["Tap"].element(boundBy: 0).tap() // first button with label "Tap"
    app.buttons["Tap"].element(boundBy: 1).tap() // second button with label "Tap"
}

Typing text requires a bit more logic#

When you want to send text to a textfield, it isn’t as easy as just using the typeText() method. You will first need to tap on it, then send the text, and then close the keyboard. Using the waitFor() method we wrote earlier in this post, we can create these methods to keep our test code clean:

// Method to hide the keyboard, and let the caller know whether it was successful or not
func hideKeyboard() -> Bool {
    var didHideKeyboard = false
    let doneButton = XCUIApplication().buttons["Done"]
    if waitFor(doneButton, toBe: .visible, secondsToWait: 5) {
        doneButton.tap()
        didHideKeyboard = true
    }
    return didHideKeyboard
}

// Method to type text into the desired textField, taking care of all the extra logic needed
func type(_ text: String, into input: XCUIElement) -> Bool {
    input.tap()
    input.typeText(text)

    //Not asserted intentionally. Not all keyboard prompts show a 'Done' button
    hideKeyboard()
    return input.value as? String == text
}

// Usage
func test_textfieldInput() throws {
    let app = XCUIApplication()
    app.launch()

    let textField = app.textFields.element(boundBy: 0)
    XCTAssert(
        type("Hello!", into: textField),
        "Could not type into the textField"
    )
}

As well as keyboard inputs, you also have to do something similar when choosing a value from a PickerView. Reusing the hideKeyboard() method from above, the code looks a little bit like this:

func select(_ value: String, from picker: XCUIElement) -> Bool {
    picker.tap()
    XCUIApplication().pickerWheels.element.adjust(toPickerWheelValue: value)
    XCTAssertTrue(hideKeyboard(), "Unable to dismiss keyboard")
    return true
}

You can ignore native elements#

When using app.textFields or app.buttons, you may sometimes find that native elements that you can’t currently see are being picked up. To prevent these from potentially interfering with your tests, you can instead use app.webviews.textFields or app.webviews.buttons, which will limit the element search to what is inside the webviews.

Conclusion#

Hopefully these tips will make your life easier when it comes to automating a webview-based iOS app. If there are any tips that you’ve found that aren’t listed here, please do reach out to me via Twitter, and keep an eye open for my upcoming tutorials.