Beliebte Suchanfragen

Cloud Native

DevOps

IT-Security

Agile Methoden

Java

//

UI testing with robots

30.9.2018 | 5 minutes of reading time

Until recently I was hesitant to recommend UI tests, as far as I was concerned they were brittle and took far too long to write and maintain. This changed after working on a project that used the robots pattern for UI testing, which was inspired by this Jake Wharton talk .

Note: This post applies the robots pattern to iOS with Swift, but it can easily be applied to other platforms in other languages.

So what are robots? In short, they are just another level of abstraction. Instead of peppering assertions and low level interactions all around, they are put inside “robots” which share a purpose, for example a navigation or a settings robot. Put this way it doesn’t seem any different than a regular helper class, but with some syntax sugar tests become much more readable and maintainable.

So instead of this:

1func test_givenDefaultVolume_launch_showsDefaultVolume() {
2    let volume = 50
3    let app = XCUIApplication()
4    app.launchArguments.append("--uitesting")
5    app.launchEnvironment["uit-volume"] = "\(volume)"
6    app.launch()
7    XCTAssertEqual(app.staticTexts["Volume Label"].label, "\(volume)%")
8    XCTAssertEqual(app.sliders["Volume Slider"].normalizedSliderPosition, CGFloat(volume) / 100.0, accuracy: 0.1)
9}

We get this:

1func test_givenDefaultVolume_launch_showsDefaultVolume() {
2    launch {
3        $0.volume = 50
4    }
5    playerRobot {
6        $0.verifyVolume(is: 50)
7    }
8}

Note: For these tests I’m using a variation of the “given_when_should” naming scheme, which I find a bit more readable.

The benefits are pretty clear, there is less code overall, it’s reusable and we have a clear context.

1context {
2    action
3}
4other context {
5    action
6    assertion
7}

Implementing Robots

In case you want to dive deeper, here is a repository with a working project with various tests. It is a simple audio player (which doesn’t actually play any audio), with an equalizer and a queue. I will be using this project as a basis for the following examples.

Here is a sample robot from the project:

1class EqualizerRobot {
2    let app = XCUIApplication()
3    lazy var volumeSlider = app.sliders["Volume Slider"]
4    lazy var bassSlider = app.sliders["Bass Slider"]
5    lazy var trebleSlider = app.sliders["Treble Slider"]
6}
7 
8// MARK: - Actions
9extension EqualizerRobot {
10    func setVolume(to value: CGFloat) {
11        volumeSlider.jiggle(toNormalizedSliderPosition: value / 100.0)
12    }
13 
14    // repeat for bass and treble ...
15}
16 
17// MARK: - Assertions
18extension EqualizerRobot {
19    func verifyVolume(is value: Int, file: StaticString = #file, line: UInt = #line) {
20        XCTAssertTrue(app.staticTexts["Volume: \(value)%"].exists, file: file, line: line)
21        XCTAssertEqual(volumeSlider.normalizedSliderPosition, CGFloat(value) / 100.0, accuracy: 0.1, file: file, line: line)
22    }
23 
24    // repeat for bass and treble ...
25}
Note: The jiggle function is there because adjust(toNormalizedSliderPosition:) is a “best effort” adjustment (according to Apple) and not precise enough for most tests. In my tests it missed +/-10%, so jiggle, jiggles the slider around until the correct value is selected. You can see the full source code for this function in the repo .
Note: file: StaticString = #file, line: UInt = #line is added to all assertion functions and passed to various XCTAssert calls, so that test failures are reported in the test and not in the robot.

As you can see, the robot is nothing special, a few convenience methods for actions and assertions. Even so, it is still important since the testing logic for a specific domain is contained in one place. With robots like this it is easy to refactor when the UI changes or to perform actions needed to get the app into the correct state for another test.

For example, in the test project Player and Equalizer screens share volume values. With robots it’s easy to adjust the volume slider on the Player screen and verify the volume value on the Equalizer screen.

1func test_changingPlayerVolume_updatesEqualizerVolume() {
2    launch {
3        $0.volume = 100
4    }
5    playerRobot {
6        $0.setVolume(to: 10)
7    }
8    tabBarRobot {
9        $0.showEqualizer()
10    }
11    equalizerRobot {
12        $0.verifyVolume(is: 10)
13    }
14}

Syntactic Sugar

Once you have a robot it is possible to use it like this:

1let playerRobot = PlayerRobot()
2playerRobot.setVolume(to: 10)
3playerRobot.verifyVolume(is: 10)

but that is only marginally more readable than what we started with, so let’s add some syntactic sugar.

First off, to simplify things we will create a base class for all test cases. It will contain convenience accessors for all of our robots.

1class BaseTestCase: XCTestCase {
2    private let playerRobot = PlayerRobot()
3    // repeat for other robots
4}
5 
6extension BaseTestCase {
7    func playerRobot(_ steps: (PlayerRobot) -> Void) {
8        steps(playerRobot)
9    }
10    // repeat for other robots
11}

Since PlayerRobot is stateless we can store it in a constant to be reused, but if your robot needs to have state remember to recreate them before each test. In complex projects, with many robots, it might be a good idea to create a robots cache which is invalidated after each test.

The function playerRobot(_ steps: (PlayerRobot) -> Void) allows us to use the nice syntax we saw earlier. By passing the robot in an inline closure and thanks to Swift’s shorthand argument naming system we can represent a context where $0 is the robot.

1playerRobot {
2    $0.setVolume(to: 10)
3    $0.verifyVolume(is: 10)
4}

You could achieve similar results by requiring all robot functions to return the robot, and then chaining calls.

1playerRobot()
2    .setVolume(to: 10)
3    .verifyVolume(is: 10)

I prefer the former approach, since you can add intermediary steps without breaking the chain or leaving the context.

1accountRobot {
2    $0.verifyUserLoggedOut()
3    let credentials = TestData.credentialsForUser(user: testUser)
4    $0.inputPassword(credentials.password)
5    $0.inputEmail(credentials.email)
6    $0.tapLogin()
7}
1app.launchEnvironment[UITestData.EnvKeys.volume] = "\(volume)"

and on the app side we need to retrieve it and use it where needed:

1if let volumeString = env[UITestData.EnvKeys.volume], let volume = Int(volumeString) {
2    player.volume = volume
3}

Again, to make this process a bit nicer, we can add some syntactic sugar.

First, we create an object that will temporarily hold all of the mocked data:

1class LaunchArguments {
2    var queue: [(title: String, albumArt: String)] = []
3    var volume: Int?
4    var bass: Int?
5    var treble: Int?
6}

and then use it in the launch function of our BaseTestCase:

1func launch(_ setup: ((LaunchArguments) -> Void)? = nil) {
2    let app = XCUIApplication()
3    app.launchArguments.append("--uitesting")
4 
5    let arguments = LaunchArguments()
6    setup?(arguments)
7 
8    app.launchEnvironment[UITestData.EnvKeys.queue] = UITestData.encodeQueue(arguments.queue)
9    if let volume = arguments.volume {
10        app.launchEnvironment[UITestData.EnvKeys.volume] = "\(volume)"
11    }
12    if let bass = arguments.bass {
13        app.launchEnvironment[UITestData.EnvKeys.bass] = "\(bass)"
14    }
15    if let treble = arguments.treble {
16        app.launchEnvironment[UITestData.EnvKeys.treble] = "\(treble)"
17    }
18 
19    app.launch()
20}

You will notice that this is very similar to the approach we took with the robots. The nice thing about it is that it allows us to selectively mock data, without having to create complex object on the test side:

1launch {
2    $0.queue = [(“Song 1”, “Art 1”)]
3    $0.volume = 11
4}

or

1launch {
2    $0.bass = 503}

share post

Likes

0

//

More articles in this subject area

Discover exciting further topics and let the codecentric world inspire you.

//

Gemeinsam bessere Projekte umsetzen.

Wir helfen deinem Unternehmen.

Du stehst vor einer großen IT-Herausforderung? Wir sorgen für eine maßgeschneiderte Unterstützung. Informiere dich jetzt.

Hilf uns, noch besser zu werden.

Wir sind immer auf der Suche nach neuen Talenten. Auch für dich ist die passende Stelle dabei.