May 15, 2019

Guide to advanced UI tests on iOS

Image source

Let’s talk about tests for iOS today — especially about writing advanced UI tests using the XCTest framework.

Before we start, I’d like you to know I will skip all the preparations — such as creating a new test target and adding pods to test target since it’s something you should already know before reading this article. Guide to these settings can be found eg here. Also, as for the languages, we will be using mainly Swift and a bit of Objective-C.

What I cover in the article

Below you find some guidelines / rules you should follow, mainly to have a nice, clean and easy-to-debug code of your UI tests. I elaborate on each of these points in the following text.

  1. Don’t rely on the test recorder
  2. Use the console to debug / find elements in your app
  3. Deal with asynchronous events using waits (buttons, rendering after a few seconds, texts visible after loading is done, etc)
  4. Use accessibility identifiers
  5. Store paths to XCUIElements in variables
  6. Use methods for repetitive test code
  7. Make use of the setUp and tearDown methods before and after test for setting and cleaning up your environment
  8. Keep your project clean

1. Don’t rely on the test recorder

Have you heard of XCode’s test recorder? Well, forget about it and try not to use it. Why? It generates a lot of unwanted junk, check it out yourself:

//code generated by test recorder
func testSomeRandomTest() {
    let app = XCUIApplication()
    let collectionViewsQuery = app.collectionViews
    collectionViewsQuery.staticTexts[”Help”].tap()
    app.navigationBars[“Help”].buttons[“Close”].tap()
    collectionViewsQuery.staticTexts[“Refer a Friend”].tap()
    app.navigationBars[“Kiwi_com.LoginChoiceView”]
    .buttons[“Cancel”].tap()
    app.collectionViews.staticTexts[“Messages”].tap()
    app.navigationBars[“Kiwi_com.LoginChoiceView”]
    .buttons[“Cancel”].tap()
}

You don’t want something like this in your code. Below is the same code, but written manually.

func testSomeRandomTest() {
    let app = XCUIApplication()
    let helpStaticText = app.collectionViews.staticTexts["Help"]
    let messagesStaticText = app.collectionViews
    .staticTexts["Messages"]
    let referStaticText = app.collectionViews
    .staticTexts["Refer a Friend"]
    let closeButton = app.navigationBars.buttons["Close"]
    let cancelButton = app.navigationBars.buttons["Cancel"]
    helpStaticText.tap()
    closeButton.tap()
    referStaticText.tap()
    messagesStaticText.tap()
    cancelButton.tap()
}

However, there is one exception to the test recorder use: you can use it to find a path to hard-to-find-with-console elements, since it is way faster than finding them manually.

2. Use the console to debug / find elements in your app

Do you have a test that keeps crashing randomly? Having an unstable and flaky test is even worse than having no test at all.

The easiest way to debug it is by turning on the “All Exceptions” breakpoint and using your console (“How to” link). If you don’t use these breakpoints, your test will just crash without you knowing what exactly happened. Not using this becomes an issue later, when your tests are made of methods and you would have to go through all these methods without the help of the console.

Crashing test without “All Exceptions” breakpoint turned on.

In contrast, when you use breakpoints, you can use your console to check what happened (I got return value of “helpStaticText.exists” false, so the element is not visible and not clickable by the test).

Crashing test with “All Exceptions” breakpoints turned on and possible console use.

3. Deal with asynchronous events with waits

Now, when talking about waits, I don’t mean sleeps. You should use sleeps only as a last resort, since it is wasted test time and it might not bring the desired effect. Instead, you should use so-called “wait methods”, in XCTest “waitForExistence” function, or other waits, that work on a similar principle (we have a custom wait method, where we can wait for any Bool condition, based on this Objective-C code).

One example for all — you have a Facebook login webview with login text fields loading as fast as your internet allows. You want to wait for this text field, so your test doesn’t try to tap on it before it is visible. Should you use “sleep” or “.waitForExistence”?

“waitForExistence” is the correct answer — if you use a short sleep, you have no way of knowing if your internet is fast enough to load the desired text field. On the other hand, if you use a longer sleep, you are wasting test time. Using “waitForExistence” or other wait-for based function solves this — you can set a long timeout, since it will end after the text field loads and won’t unnecessarily prolong your test run.

An example of text field waiting with sleep:

Sleeping test is a bad test. It took this test ~30 seconds to end, while the element was already loaded and hittable.

An example of the same code but with “waitForExistence”:

This wait took only ~3.5 seconds to finish.

4. Use accessibility identifiers

There are several options how to identify an element:

  • Text on the element
  • Index of the element
  • The position of the element on the screen
  • Using an accessibility identifier

Let’s take an ordinary button with some text (but this can be used on any XCUIElement) — this button has the text “Sign In” on it.

A/ Text on the element:

The identification of the element is done by searching the app layout for the element with a certain text. It’s not as easy as it might sound, since you have to combine the path to the element and its text.

Let’s take this “Sign In” button — the path to this element by string is app.buttons[“Sign In”], as seen on this gif:

Identification of button by its text.

Identification of an element by its string is not recommended, though. In-app element strings are often changed, and you would have to debug and repair your tests every time someone changes a string on the element you are using.

It is also possible to use LocalizedStrings, as shown here.

B/ Index of the element

This kind of identification uses “element(boundBy: Int)” XCTest method. You are looking for a certain element, based on its position, relative to the current view layout.

Identification of “Sign In” button by its index.

Again, I would not recommend this kind of element identification since the layout can change quite often (same as with the by-string identification).

C/ Position of the element on the screen

You can use the position of the element (based on coordinates) to identify and interact with it. I’ve come up with two methods for getting the element position and tapping on it. First, you get the element position as CGFloat like this:

//This method returns element’s position as coordinates.
func getElementPositionAsCGFloat(element: XCUIElement) -> (x: CGFloat, y: CGFloat) {
    let frame = element.frame
    let xPosition = frame.origin.x
    let yPosition = frame.origin.y
    let screenWidth = UIScreen.main.fixedCoordinateSpace
    .bounds.width
    let screenHeight = UIScreen.main.fixedCoordinateSpace
    .bounds.height
    let elementXPositionAsPortionOfScreen = xPosition / screenWidth
    let elementYPositionAsPortionOfScreen = yPosition / screenHeight
    return (
      x: elementXPositionAsPortionOfScreen,
      y: elementYPositionAsPortionOfScreen
    )
}

and then I tap on the element, using the following method:

func tap(on element: XCUIElement, xOffset: CGFloat, yOffset:    CGFloat) {
    let coordinate = element.coordinate
    (withNormalizedOffset: CGVector(dx: xOffset, dy: yOffset))
    if element.waitForExistence(timeout: 5) {
      coordinate.tap()
    } else {
      // fail the test
    }
}

Both methods are called like this:

let signInButton = app.collectionViews.cells.buttons["Sign In"]
let position = getElementPositionAsCGFloat(element: signInButton)
tap(on: self.app, xOffset: position.x, yOffset: position.y)

I can recommend this way of identification only for tapping/interacting with a certain part of some element — button / string… (if you need to tap somewhere on the string, which is not a dead centre of it). However, don’t use this procedure for identifying a regular elements used in UI tests.

We have this string / button for registration — only a part of this string is hittable (“Register”) and you cannot tap on an exact word from the string with regular XCTest methods. That’s when you would use this method of identification and tapping.

Part of the string on which we want to tap.

D/ Using an accessibility identifier

An accessibility identifier is by far the best property and way to identify an element in UI tests. It is a unique string that can be set to an element upon its creation and later used in UI tests instead of the element’s text. The full documentation can be found here.

Let’s have this same “Sign In” again and let’s set its “accessibilityIdentifier” property to it, like this:

//creating "Sign In" button with accessibility identifier
@objc public let loginButton: UIButton = {
    let button = RoundedButton(style: .primary)
    button.setTitle(LocalizedString(“account.log_in”), for: .normal)
    button.accessibilityIdentifier =
    AccessibilityIdentifier.signButtonInProfile.rawValue
    return button
}()

This element’s unique identifier is now “signInButton” and we can identify and interact with this element from our test / console like this:

Using the accessibilityIdentifier property to identify a button.

However, this also means that your code will be sooner or later full of strings with no way of keeping track of them. For this reason, I would recommend having one file with all of your accessibility identifiers. This way you can use them in code AND in tests from one source without having them in strings as shown in the following images:

Accessibility identifiers put to one “.h” file:

#import <Foundation/Foundation.h>
typedef NSString * _Nonnull AccessibilityIdentifier __attribute__((swift_wrapper(enum)));
extern AccessibilityIdentifier const AccessibilityIdentifierDepartureReturnOptionDates;
extern AccessibilityIdentifier const AccessibilityIdentifierDepartureReturnOptionAnytime;
extern AccessibilityIdentifier const AccessibilityIdentifierDepartureReturnOptionNoReturn;
extern AccessibilityIdentifier const AccessibilityIdentifierDepartureReturnOptionTimeToStay;

Accessibility identifiers put to one “.m” file:

#import "AccessibilityIdentifier.h"
AccessibilityIdentifier const AccessibilityIdentifierDepartureReturnOptionDates = @"dates";
AccessibilityIdentifier const AccessibilityIdentifierDepartureReturnOptionAnytime = @"anytime";
AccessibilityIdentifier const AccessibilityIdentifierDepartureReturnOptionNoReturn = @"noReturn";
AccessibilityIdentifier const AccessibilityIdentifierDepartureReturnOptionTimeToStay = @"timeToStay";

Use of such an accessibility identifier when creating a button in Objective-C:

_facebookLoginButton = [[RoundedButton alloc] 
initWithStyle:RoundedButtonStyleFacebook];
_facebookLoginButton.accessibilityIdentifier = AccessibilityIdentifierFacebookSignInButton;
[_facebookLoginButton setTitle:[NSString stringWithFormat:LocalizedString(@”account.log_in_with”), @”Facebook”] forState:UIControlStateNormal];
[_facebookLoginButton setImage:[IconCharacters imageWithCharacter:IconCharacterFacebook iconSize:IconSizeDefault color:[KiwiColor white]] forState:UIControlStateNormal];
[self addSubview:_facebookLoginButton];

Use of such an accessibility identifier when creating a button in Swift:

@objc public let loginButton: UIButton = {
    let button = RoundedButton(style: .primary)
    button.setTitle(LocalizedString("account.log_in"), for: .normal)
    button.accessibilityIdentifier = 
    AccessibilityIdentifier.signButtonInProfile.rawValue
    return button
}()

Use in tests:

let signInButton = app.collectionViews.cells
.buttons[AccessibilityIdentifier.signButtonInProfile.rawValue]
if signInButton.waitForExistence(timeout: 20) {        
    signInButton.tap()
}

5. Store paths to XCUIElements in variables

Let’s use the same test (while using accessibility identifiers for identification instead of using string) we used for the “test recorder” part in this article:

//code written manually
func testSomeRandomTest() {
    let app = XCUIApplication()
    let helpStaticText = app.collectionViews.staticTexts["helpID"]
    let messagesStaticText = app.collectionViews
    .staticTexts["messagesID"]
    let referStaticText = app.collectionViews
    .staticTexts["referID"]
    //we cannot add IDs to navigation bar elements - we have to use
     "element(boundBy: )" or identification, using string.
     See below code:
    let closeButton = app.navigationBars.buttons.element(boundBy: 0)
    let cancelButton = app.navigationBars.buttons["Cancel"]
    helpStaticText.tap()
    closeButton.tap()
    referStaticText.tap()
    messagesStaticText.tap()
    cancelButton.tap()
}

On the lines starting with “let” we can see the declarations of variables. These variables of XCUIElement type contain full path to some element (button, staticText etc). Having them in a variable allows us to use them multiple times.

On the other hand, the test is the same when you don’t use variables for path storing:

func testSomeRandomTest() {
    app.collectionViews.staticTexts["helpID"].tap()
    app.collectionViews.staticTexts["messagesID"].tap()
    app.collectionViews.staticTexts["rafID"].tap()
    app.navigationBars.buttons.element(boundBy: 0).tap()
    app.navigationBars.buttons["Cancel"].tap()
}

Looks fine, right?

Yep. But check out what happens when you add some other actions to this test:

func testSomeRandomTest() {
    app.collectionViews
    .staticTexts["helpID"].waitForExistence(5)
    app.collectionViews
    .staticTexts["messagesID"].waitForExistence(5)
    app.collectionViews.staticTexts["rafID"].waitForExistence(5)
    app.navigationBars.buttons.element(boundBy: 0)
    .waitForExistence(5)
    app.navigationBars.buttons["Cancel"].waitForExistence(5)
    app.collectionViews.staticTexts["helpID"].tap()
    app.collectionViews.staticTexts["messagesID"].tap()
    app.collectionViews.staticTexts["rafID"].tap()
    app.navigationBars.buttons.element(boundBy: 0).tap()
    app.navigationBars.buttons["Cancel"].tap()
    if app.textTextFields["randomTextField"].exists {
      app.textTextFields["randomTextField"].tap
 
    }
}

What happens if you use variables instead? Well, it might add a few lines to your code but it’s way more readable.

func testSomeRandomTest() {
    //declaration of variables:
    let helpStaticText = app.collectionViews.staticTexts["helpID"]
    let messagesStaticText = app.collectionViews
    .staticTexts["messagesID"]
    let referStaticText = app.collectionViews.staticTexts["rafID"]
    let closeButton = app.navigationBars.buttons.element(boundBy: 0)
    let cancelButton = app.navigationBars.buttons["Cancel"]
    let randomTextField = app.textFields["randomTextField"]
   //waiting for elements:
    helpStaticText.waitForExistence(5)
    messagesStaticText.waitForExistence(5)
    referStaticText.waitForExistence(5)
    closeButton.waitForExistence(5)
    cancelButton.waitForExistence(5)
    //tapping on elements:
    helpStaticText.tap()
    messagesStaticText.tap()
    referStaticText.tap()
    closeButton.tap()
    cancelButton.tap()
    //tapping on some textfield:
    if randomTextField.exists {
      randomTextField.tap()
    }
}

6. Use methods for repetitive test code

Do you have more tests and a complicated app layout? If so, you will most likely use the same code in multiple tests. The best way to deal with this is to store this code into a reusable method.

How to do this elegantly? Try creating classes for these test-support methods and extending your test class by them, so you don’t have to have test methods and support methods in one class.

Let’s take this test as an example:

func testOpenPriceAlertFromProfile() {
    logInViaEmailFromTabBar(
      email: "[email protected]",
      password: "somePassword"
    )
    waitForPriceAlertAndTapOnIt()
    pressFirstButtonInNavigationBarFromLeft()
}

The actual test code is in methods, used this test.

The first method (“logInViaEmailFromTabBar()”) logs in to users account in our tests.

func logInViaEmailFromTabBar(
   email: String,
   password: String,
   expectNotifications: Bool = true
) {
    let signInButton = app.collectionViews
    .cells.buttons[.signButtonInProfile]
    let emailLoginButton = app.buttons[.emailLoginButton]
    let loginWithPasswordField = app.scrollViews.otherElements
    .secureTextFields[.loginViewPasswordTextField]
    let loginWithEmailField = app.scrollViews.otherElements
    .textFields[.loginViewEmailTextField]
    let submitButton = app.scrollViews.otherElements
    .buttons[.loginViewSubmitButton]
    switchToTab(.profile)
    if signInButton.waitForExistence(timeout: 5) {
    //if "sign in" button exists, do a login:
     signInButton.tap()
     emailLoginButton.tap()
     _ = clearTextfieldIfNeeded(textfield: loginWithEmailField)
     type(text: email, into: loginWithEmailField, pressEnter: true)
     type(text: password, into: loginWithPasswordField, pressEnter: 
     false)
     submitButton.tap()
     if expectNotifications {
       handleNotificationsIfNeeded()
     }
    } else {
     //if it doesn't, fail the test
    }
}

If you check the code of this method (image above) you can see that it further uses other predefined methods to do actions (“logOut”, “doALogIn”…) since the code in these methods is repetitive and used in more methods / tests. The login method is used in 39 places in 13 test classes / files, saving hundreds of lines of code.

Use methods for repetitive code. Please. ❤

7. Make use of the setUp and tearDown methods

setUp and tearDown blocks are called before and after each test. They are a great way to prepare your app and test device and turn it into the desired pre-test state.

Do you want to log out the user and do a language setup of your app before every test? Do you want to log the name of your test? Use the setUp block.

Do you want to uninstall your app? Clean the test device? Kill the app? Use the tearDown block.

This is how our setUp block looks like:

//this block of code will run before every test:
override func setUp() {
    super.setUp()
    initialConfiguration(withLanguage:  "en-US", locale: "en-US")
}

Method “initialConfiguration” runs before every test and contains the following code:

func initialConfiguration(
   withLanguage language: String,
   locale: String,
   logOutNeeded: Bool = true
) {
    testContext.testLanguage = language
    continueAfterFailure = false
    app.launchArguments += ["-AppleLanguages", "(\(language))"]
    app.launchArguments += ["-AppleLocale", "\"\(locale)\""]
    app.launchArguments += [UITesting.launchArgument]
    app.launch()
    restoreInitialAppState(logOutNeeded: logOutNeeded)
}

As you can see, we are using this block to set up the app — its language and locale — launch it and restore some initial state (logOut user, go through the splash screen if needed etc) before every test.

8. Keep your project clean

A clean project is a good project.

Place test classes into their own files. Place helper methods into their own files. Place all utility classes into their own files.

Place all test-classes files into one folder, place all methods to another folder in your project. Do the same with utilities.

A clean project is a good project.

Conclusion

Approach your tests as any other code you would add to your app — keep it clean, readable and easy to maintain. Use all the features of Swift and XCTest library, it can save you a lot of time. You can extend your repetitive methods into your tests, use the external storage of your accessibility identifiers, learn to identify your XCUIElements properly and keep your tests stable and fast.

Do you want to join me in making travel better? Check the open positions for our mobile team.

Did you enjoy reading this post? Do you have any questions? Contact the author via LinkedIn or leave a comment under this article.

Search
Share
Featured articles
Generating SwiftUI snapshot tests with Swift macros
Don’t Fix Bad Data, Do This Instead