In addition to unit tests, there is another form of automated testing, user interface
testing, or UI testing for short. While unit tests really focus on a delimited unit or
function, UI tests simulate the actual behavior of a user interacting with the app.
However, this also means that there are significantly more things to consider so that UI
tests are deterministic just like unit tests, i.e. they lead to the same result every
time they are run.
This can be demonstrated well using the example of an iOS app. In this example app, there
are three functions that can all be tested with UI tests. If a text is entered, it
appears in the navigation bar after confirmation. If you press the button, an alert with
the title "Good Morning" appears. Finally, the switch can be used to set whether the
button can be pressed or not.
The first UI test
For writing a UI test, Apple's XCTest framework can be used, just like for unit tests. In
contrast to unit tests, however, you have to start the app in each test case and
describe how to interact with which elements.
Launching the app is done as follows:
let app = XCUIApplication()
app.launch()
The individual UI elements can now be accessed via the app variable. For example, the button in the example above can be read via
the title.
let button = app.buttons["Show Alert"]
The disadvantage of this way, however, is that you would have to update the UI test if
for some reason the title of the button would change. To avoid this, you can set the
accessibilityIdentifier on each UIKit element, which is intended exactly for UI tests.
This can be done either in the code or you can set it in the storyboard or Xib file in
the Identity Inspector of the respective view. Similarly, in SwiftUI there is the
modifier .accessibility(identifier:).
If you now give the button the accessibilityIdentifier "Example.Button", it can be accessed in the UI test via this string.
let button = app.buttons["Example.Button"]
In order to test certain conditions, one uses, as in unit tests, the assertions, which
XCTest offers. To ensure that the button can be pressed, you can proceed as follows:
XCTAssertTrue(button.isEnabled)
The last step of our first UI test is to check that the button can no longer be pressed
after deactivating the switch. For this purpose, the switch is read on the basis of its
assigned accessibilityIdentifier and pressed once. Finally, we check whether the button
is no longer active.
let uiSwitch = app.switches["Example.Switch"]
uiSwitch.tap()
XCTAssertFalse(button.isEnabled)
Xcode also offers the possibility to record certain interactions that are to be tested.
This means you can interact with UI elements in the simulator and the recorder
translates this into code for a UI test. In most cases not very reliable and the code is
usually more complicated than necessary, but it can serve as an entry point. A recording
can be started via the red record button below the editor when the cursor is in a test
method.
Alerts and Navigation Bar
After completing the first UI test, we can follow the same pattern for our other two scenarios.
To test that the button triggers an alert, it is read again and finally pressed via the
tap() method. An alert can be read by its title, but you can also use the element
property, which can always be used if there is only one element of the corresponding
type. For example, if there is only one button on the screen you could also use
app.buttons.element to access the button. Similarly, the firstElement property will
return the first matching element, for example, if it doesn't matter which concrete
element of the type is needed.
Now we can use the exists property to make sure that the alert is really displayed and
finally close it via the OK button. This is how the whole thing looks like in the
end:
let app = XCUIApplication()
app.launch()
let button = app.buttons["Example.Button"]
button.tap()
let alert = app.alerts.element
XCTAssertTrue(alert.exists)
alert.buttons["OK"].tap()
The last UI test is to ensure that the text entered in the text field is displayed in the
navigation bar after confirmation. Text fields can also be read out via the app
variable. When entering the text there are a few things to keep in mind. First, the text
field must be given focus by selecting it with tap(). After that enter any string with
the typeText() function. In order for the text to be written to the navigation bar, we
have to press the Return button of the keyboard. At this point, it is extremely
important in the UI test to have the Software Keyboard in the simulator (I/O -> Keyboard
-> Toggle Software Keyboard), otherwise the return button cannot be found.
Finally, the Navigation Bar can also be read out via its title, whereupon it is checked
whether it is actually present.
let app = XCUIApplication()
app.launch()
let textField = app.textFields["Example.TextField"]
textField.tap()
textField.typeText("Good Morning")
app.keyboards.buttons["Return"].tap()
let navigationBar = app.navigationBars["Good Morning"]
XCTAssertTrue(navigationBar.exists)
Stumbling blocks
An important point to consider in UI testing is flakiness. A test is said to be flaky
when sometimes it passes successfully but other times it doesn't. There can be many
reasons for this. One reason can be that a state from a previous test was not reset and
the app is not in the correct state, so for example, an onboarding screen that is only
displayed the first time the app is launched is not displayed the 2nd time the UI test
is run.
A common way to avoid this is to use launch arguments. These can be given to the app
before launch in the UI test and read out in the app's code. For example, this way we
can define that we want to reset the app on every run:
let app = XCUIApplication()
app.launchArguments = ["reset-app"]
app.launch()
In AppDelegate or SceneDelegate it can be checked if the reset argument is present and if
necessary the database or UserDefaults can be reset, depending on the app scenario.
if CommandLine.arguments.contains("reset-app") {
resetApp()
}
In contrast to unit tests, where network calls are usually not actually executed, they
are executed normally in UI tests. However, this can also lead to elements that are only
displayed after loading the data from the server, for example, not being directly
available in the UI test.
If a button is only displayed after a request to the server has been successfully
executed, this can be taken into account in the UI test by means of a delay. A timeout
can be defined via the waitForExistence method, which basically states that the duration
of the timeout is waiting for the element to exist. Only if the condition is not
fulfilled after the timeout, the test is considered as failed.
XCTAssertTrue(timelineCell.waitForExistence(timeout: 2))
Launch Arguments and the waitForExistence method are very useful to ensure that UI tests
are deterministic. Only when every run of the test leads to the same result is it really
useful.
Wrapping Up
As has been shown, UI tests are a very helpful means of ensuring the quality of an app.
On the one hand, certain workflows can be tested, but it can also be ensured that the UI
looks good on a wide range of device sizes. In addition, basically every interaction and
gesture of the user can be mapped. Only WebViews or scenarios in which the device's
sensors are accessed are less suitable.
Since it takes more effort to write and execute UI tests than e.g. unit tests, you should
develop a certain feeling for where it makes sense to use them.