5 tips for painless UI Testing

UI Testing is a great thing to keep apps maintainable and reliable during its whole lifecycle, but sometimes this user interface testing could become a big pain.

Here we will transform a bad test case into a better one, applying some code refactors.

In this article, it’s assumed that the reader knows some basic usage of UI testing.

The bad test case

func test1() {    // 1. The user taps in a textfield
app.textfields["Send something..."].tap()
// 2. The user types the text “Hello
app.textfields["Send something..."].type("Hello")
// 3. The user taps send button
app.buttons["Send"].tap()
// 4. If the text in the label is “How are you?”, the text will pass.
XCTAssert(app.labels["result"].text == "How are you?")
}

At first sight, this code is understandable and easy to follow, but when new features and code changes arrived…

  • Test plans become larger and difficult to maintain.
  • UI Elements changed during development make tests stop working and are needed to be refactored one time after another.
  • New test cases like this will provoque a lot of code duplication, with the risk of hard refactors needed after every new development.

Let’s see how to fix this!

1. Test case naming

func test_<what_to_test>_<conditions>() {
...
}

Where:

test_: Indicates that this is an Xcode test case method. 
Note: In case you need a helper function, you must not include this prefix.
<what_to_test>: Indicates the purpose of your test.<conditions>: Indicates the conditions set for the test case.

So, in the previous test case will become this:

func test_sendMessage_hello() {
app.textfields["Send something..."].tap()
app.textfields["Send something..."].type("Hello")
app.buttons["Send"].tap()
XCTAssert(app.labels["result"].text == "How are you?")
}

2. Test case structure

Given the scenario X, when I make the action X, then I got the result X

A title on each step should be included to clarify the structure. In the test case, also an initial configuration is made fo textField and the message to send has been included.


func test_sendMessage_hello() {
// Given
textField.text = ""
let messageToSend = "Hello"
// When
app.textfields["Send something..."].tap()
app.textfields["Send something..."].type("Hello")
// Then
app.buttons["Send"].tap()
XCTAssert(app.labels["result"].text == "How are you?")
}

3. UI elements: Access and interaction

app.textfields["Send something..."].tap()

Xcode detected you tapped on a textfield that has the placeholder “Send something”. If the placeholder is changed in a later code update, the test will stop working, and it will have to be refactored.

To avoid this scenario, let’s use accessibility identifiers.

Accessibility identifiers helps people with disabilities to use apps. Applied to UI testing, they allow the elements to be identified.

In the viewController class related to the test, some identifiers can be defined:

class ViewController: UIViewController {
@IBOutlet weak var inputTextField: UITextField! {
didSet {
inputTextField.accessibilityIdentifier = "input_text_field"
}
}
@IBOutlet weak var sendButton: UIButton! {
didSet {
sendButton.accessibilityIdentifier = "send_button"
}
}
@IBOutlet weak var resultLabel: UILabel! {
didSet {
resultLabel.accessibilityIdentifier = "result_label"
}
}
}

UI Testing includes some useful methods that can be used to init and reset our test cases shared data.

  • setUp(): Sets up a test case before it is launched.
  • tearDown(): Clears data after the test case finishes.

On XCUITests, an element on the UI can be accessed by its identifier from an array that includes every UI element of a kind on the current visible screen of the app: app.buttons, app.textfields, app.labels, etc.

For the test case, some variables for the UI elements will be initialized on the setUp method:

class UITests: XCTestCase {
var app: XCUIApplication!
var inputTextField: XCUIElement!
var sendButton: XCUIElement!
var resultLabel: XCUIElement!
override func setUp() {
app = XCUIApplication()
sendButton = app.buttons[“send_button”]
inputTextField = app.textfields[“input_text_field”]
resultLabel = app.labels["result_label"]
}
}

Based on these new variables, a little refactor on the test case can be made. The elements keep identified no matter what their value are (button title, label text, textfield placeholder, etc.).

func test_sendMessage_Hello() {    // Given
textField.text = ""
let messageToSend = "Hello"
// When
inputTextField.tap()
inputTextField.type("Hello")
sendButton.tap()
// Then
XCTAssert(resultLabel)
XCTAssert(resultLabel.text == "How are you?")
}

4. Reusing methods

For reusing some code between test cases, let’s create some helper methods with the most repeated actions in the test cases.

func typeTextOnInputBar(_ text: String) {
inputTextView.tap()
inputTextView.typeText(text)
}
func tapSendButton() {
sendButton.tap()
}

Joining this two methods, we get:

func sendMessage(_ message: String) {
typeTextOnInputBar(message)
tapSendButton()
}

And the test case will be like this:

func test_sendMessage_Hello() {    // Given
textField.text = ""
let messageToSend = "Hello"
// When
sendMessage("Hello")
// Then
XCTAssert(labelResult)
XCTAssert(labelResult.text == "How are you?")
}

5. Better assertions

XCTAssert statements indicate whether a condition is OK or it is not.

An XCTAssert statement is composed like this:

<XCTAssert>(<condition>, <error_message>)

Where:

XCAssert: Kind of assertion: eg. XCAssertTrue -> If the condition is true, it is ok.condition: The condition we want to check in a string format.error_message: The message that is shown if the condition is not fulfilled. This is important, so we get a quick understanding about why the test fails.

Let’s update our test case:

func test_sendMessage_Hello() {    // Given
textField.text = ""
let messageToSend = "Hello"
// When
sendMessage("Hello")
// Then
XCTAssertTrue(labelResult.text == "How are you?", "Invalid text result")
}

Conclusion

Happy coding!

Senior iOS Engineer @ Expedia Group