UI Testing in iOS - Robot Pattern

Most of the time, UI Testing gets abandoned because it becomes harder to maintain over time and one reason for that is: readability. We also tend to forget that UI tests should not only serve the purpose of verification but provide meaningful documentation that everybody (including non-developers) can understand. Alright, let’s get right into it, fellas!

What if we could write UI Tests in a human-readable language, that even a Product Owner could understand?

🎬 Hi there, I’m Jean!

In this article, I am going to introduce a better way to write UI Tests using XCTest! 😃
Note: third-party libraries such as Appium or Calabash won’t be part of this post.

The usual way


Usually, all UI Testing logic is bundled in one test function. Which inevitably leads to this…

func uglyUITest() {
    ...
    app.launch()
    let title = app.navigationBars.firstMatch.otherElements.firstMatch
    XCTAssert(title.isHittable)
    XCTAssertEqual(title.label, "U.S. - Politics")
    let stackView = app.otherElements["StoryView.stackView"]
    let multimediaImage = stackView.images["StoryView.multimediaImageView"]
    XCTAssert(multimediaImage.isHittable)
    let titleLabel = stackView.staticTexts["StoryView.titleLabel"]
    XCTAssertEqual(titleLabel.label, "A Trump Endorsement Can Decide a Race. Here’s How to Get One.")
    let abstractLabel = stackView.staticTexts["StoryView.abstractLabel"]
    XCTAssertEqual(abstractLabel.label, "The president’s grip on G.O.P. primary voters is as strong as it has been since he seized the party’s nomination.")
    let bylineLabel = stackView.staticTexts["StoryView.bylineLabel"]
    XCTAssertEqual(bylineLabel.label, "By JONATHAN MARTIN and MAGGIE HABERMAN")
    snapshot("Story")
}

I am sure you didn’t even finish reading that you already jumped to this line.
And to be fair, I’m not angry at you; this UI Test was fairly unreadable! 😵

And the main reason for that is:

Common UI Tests are mixing the What and the How altogether, in one place.

Well, that’s obviously a problem, as this isn’t following the Separation of Concern Clean Code teaches us… 🤔

Beep… Beep… Boop… 🤖


That’s right! Introducing, the Robot Pattern by Jake Wharton! 👏
The idea Jake had for the Android community is to separate in a UI Test the What from the How.
And to achieve this, he introduced a new layer called: Robot.

Transcribed to iOS (Swift), our UI Test now looks like this…

func prettyUITest() {
    ...
    StoryRobot(app)
        .start()
        .checkTitle(contains: "U.S. - Politics")
        .checkStoryImage()
        .checkStoryTitle(contains: "A Trump Endorsement Can Decide a Race. Here’s How to Get One.")
        .checkStoryAbstract(contains: "The president’s grip on G.O.P. primary voters is as strong as it has been since he seized the party’s nomination.")
        .checkStoryByline(contains: "By JONATHAN MARTIN and MAGGIE HABERMAN")
        .takeScreenshot(named: "Story")
}

How about that, didn’t it feel right to read the whole function this time? 😍
Exactly, in the UI Test itself, we are now left only with the What and the How is delegated to the Robot. Meaning: anybody can read and understand it! 👍

The Robot in action!


First and foremost, we are going to implement a base Robot.
This class will take care of setting up Fastlane’s Snapshot, launching the app and providing a nice panel of util functions, such as tapping on elements, making assertions and taking screenshots… 👌

class Robot {

    private static let defaultTimeout: Double = 30

    var app: XCUIApplication

    lazy var navigationBar       = app.navigationBars.firstMatch
    lazy var navigationBarButton = navigationBar.buttons.firstMatch
    lazy var navigationBarTitle  = navigationBar.otherElements.firstMatch

    init(_ app: XCUIApplication) {
        self.app = app
        setupSnapshot(app)
    }

    @discardableResult
    func start(timeout: TimeInterval = Robot.defaultTimeout) -> Self {
        app.launch()
        assert(app, [.exists], timeout: timeout)

        return self
    }

    @discardableResult
    func tap(_ element: XCUIElement, timeout: TimeInterval = Robot.defaultTimeout) -> Self {
        assert(element, [.isHittable], timeout: timeout)
        element.tap()

        return self
    }

    @discardableResult
    func assert(_ element: XCUIElement, _ predicates: [Predicate], timeout: TimeInterval = Robot.defaultTimeout) -> Self {
        let expectation = XCTNSPredicateExpectation(predicate: NSPredicate(format: predicates.map { $0.format }.joined(separator: " AND ")), object: element)
        guard XCTWaiter.wait(for: [expectation], timeout: timeout) == .completed else {
            XCTFail("[\(self)] Element \(element.description) did not fulfill expectation: \(predicates.map { $0.format })")
            return self
        }

        return self
    }

    @discardableResult
    func checkTitle(contains title: String, timeout: TimeInterval = Robot.defaultTimeout) -> Self {
        assert(navigationBar, [.isHittable], timeout: timeout)
        assert(navigationBarTitle, [.contains(title)], timeout: timeout)

        return self
    }

    @discardableResult
    func takeScreenshot(named name: String, timeout: TimeInterval = Robot.defaultTimeout) -> Self {
        snapshot(name, timeWaitingForIdle: timeout)

        return self
    }
    
    @discardableResult
    func back(timeout: TimeInterval = Robot.defaultTimeout) -> Self {
        tap(navigationBarButton, timeout: timeout)

        return self
    }
}

Simple, right?
Oh, by the way, here is the Predicate enum I am using for making assertions, you’ll find it quite handy! 😎

enum Predicate {
    case contains(String), doesNotContain(String)
    case exists, doesNotExist
    case isHittable, isNotHittable

    var format: String {
        switch self {
        case .contains(let label):
            return "label == '\(label)'"
        case .doesNotContain(let label):
            return "label != '\(label)'"
        case .exists:
            return "exists == true"
        case .doesNotExist:
            return "exists == false"
        case .isHittable:
            return "isHittable == true"
        case .isNotHittable:
            return "isHittable == false"
        }
    }
}

Alright! Now our Base Robot class is ready, let’s inherit from it in the StoryRobot and see how it plays out! 🤖

final class StoryRobot: Robot {

    private lazy var stackView       = app.otherElements["StoryView.stackView"]
    private lazy var multimediaImage = stackView.images["StoryView.multimediaImageView"]
    private lazy var titleLabel      = stackView.staticTexts["StoryView.titleLabel"]
    private lazy var abstractLabel   = stackView.staticTexts["StoryView.abstractLabel"]
    private lazy var bylineLabel     = stackView.staticTexts["StoryView.bylineLabel"]

    @discardableResult
    func checkStoryImage() -> Self {
        assert(multimediaImage, [.exists])

        return self
    }

    @discardableResult
    func checkStoryTitle(contains storyTitle: String) -> Self {
        assert(titleLabel, [.isHittable, .contains(storyTitle)])

        return self
    }

    @discardableResult
    func checkStoryAbstract(contains storyAbstract: String) -> Self {
        assert(abstractLabel, [.isHittable, .contains(storyAbstract)])

        return self
    }

    @discardableResult
    func checkStoryByline(contains storyByline: String) -> Self {
        assert(bylineLabel, [.isHittable, .contains(storyByline)])

        return self
    }
}

That’s it! We’ve just implemented the How of our UI Test! 🎊
What’s also really nice about Robots, is that they can cross call themselves without breaking the continuity of our UI Tests! 🤯

For instance, let’s say we want to open Safari in the StoryUITest.
All we need to do is create a SafariRobot which will take care of UI Testing the browser and create a function inside the StoryRobot that will open Safari as well as return the SafariRobot! 😮

lazy var urlButton = stackView.buttons["StoryView.urlButton"]

@discardableResult
func openSafari() -> SafariRobot {
    tap(urlButton)

    return SafariRobot(app)
}

And as you can see now, the continuity of our UI Test isn’t disturbed…

final class StoryUITest: XCTestCase {
    
    private lazy var app = XCUIApplication()
  
    func testStory() {
        StoryRobot(app)
            .start()
            .checkTitle(contains: "U.S. - Politics")
            .checkStoryImage()
            .checkStoryTitle(contains: "A Trump Endorsement Can Decide a Race. Here’s How to Get One.")
            .checkStoryAbstract(contains: "The president’s grip on G.O.P. primary voters is as strong as it has been since he seized the party’s nomination.")
            .checkStoryByline(contains: "By JONATHAN MARTIN and MAGGIE HABERMAN")
            .takeScreenshot(named: "Story")
            .openSafari()
            .checkURL(contains: "https://www.nytimes.com/2018/08/27/us/politics/trump-endorsements.html")
            .closeSafari()
    }
}

Boom! 💥
We just implemented the Robot Pattern! 🎉
UI Testing is now easy, elegant and readable! 💪

Worth a try? Did you regain faith in UI Testing? If so, follow me on Twitter, I’ll be happy to answer any of your questions and you’ll be the first one to know when a new article comes out! 👌

Want to read more about UI Testing?
Check out UI Testing in iOS - Generating Accessibility Identifiers using Reflection, as well as my 3 parts series:

  1. Painless UI Testing in iOS (Part 1) - Mocking the Network
  2. Painless UI Testing in iOS (Part 2) - Stubbing the Navigation
  3. Painless UI Testing in iOS (Part 3) - Disabling Animations

If you’d like to see a real-life example of an app doing extensive UI Testing, don’t hesitate to check out my Github repo! 📦

Until next time, happy testing! 🤖

Bye Bye! 👋