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 .
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}
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}
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 .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}
Test Data
Finally, after creating the robots and adding the syntactic sugar, we can take a quick look at passing mocked data to our app.
In this case the required data is pretty simple (volume, bass, treble, queue
) so we can pass it through the process environment. Since encoding and decoding queue
is a bit more involved, I will use volume
as an example. Don’t forget you can check out the repo to see the whole source code.
On the test side we first need to store the volume:
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 = 50 3}
Conclusion
Just because test code won’t be shipped to the end user, doesn’t mean we can’t apply the same principles and care as with actual app code. Doing so makes the end product better and our jobs a bit easier. I hope this article encouraged you to write (at least some) UI tests for your app or, if you already have them, to reconsider if they can be made better.
For a more in-depth explanation of the robots pattern I highly recommend watching the talk by Jake Wharton. For more ways to pass data between tests and app, I recommend this article by Mladen Jakovljevic.
And as always, don’t forget to check out the full project .
More articles
fromNikola Lajic
Your job at codecentric?
Jobs
Agile Developer und Consultant (w/d/m)
Alle Standorte
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.
Blog author
Nikola Lajic
Do you still have questions? Just send me a message.
Do you still have questions? Just send me a message.