How to (re)set the iOS Application State in UI Tests

No Comments

Writing UI tests for iOS applications using the XCTesting Framework where my app had some saved state between runs has caused me a lot of problems. To save the state locally, I used UserDefaults. Therefore I will show how to overcome such problems. To understand better why these problems occur, let’s go through an example.

Onboarding Example

Imagine that I have an onboarding process in my iOS application where I want to make sure that the user of the app sets up some preferences before using the app. Let’s say that I have two switches where changing the state of each switch changes the corresponding caption text. Other than that, I have the Continue button on the bottom and I’m only enabling it when both of the switches are turned on. Something like this is seen in the image below.

Onboarding example with two switches and Continue button

In order to save the state of the switches for further use within the app, I’m saving the state using UserDefaults (although allowing the user to continue to the next screen only if both switches are turned on doesn’t make use of knowing the state of the switches later, but let’s keep it this way for the sake of this example). Let me save the state in keys called keepScreenOnKey and autoRefreshKey.

Test Cases

If I want to do some UI testing, I could write a couple of very simple UI tests to verify if my app behaves as intended. Some of the test cases can be:

  1. Test whether turning on the first switch changes the first caption text.
  2. Test if the correct caption text is displayed upon startup when the first switch is on.
  3. Test whether turning on the second switch changes the second caption text.
  4. Test if the correct caption text is displayed upon startup when the second switch is on.
  5. Test whether turning on both switches enables the Continue button.

The Problem

If I want to know what text should be displayed on the screen when the Keep screen on switch is turned on, I have to make sure that that switch is always turned off before every test run of the first test case. The same applies to the third test case. For second (or fourth) test case I need to make sure that the first (or second) switch is always turned on when starting the app. For the fifth test case, I want to have both switches turned off.

But why wouldn’t I have all switches turned off when the app starts? Because I saved the state of the switches to UserDefaults, and when the app was killed between test runs and started again, the states are restored to what they used to be. In other words, when the UI test for the first test case turns on Keep screen on switch, it stays turned on when the next UI test starts. If the next UI test expects that switch to be turned off, it won’t be, and here’s the problem: UI tests start to fail because the initial state is not set to correct values.

Solutions

Since it’s impossible to access the app’s internal methods, functions, and variables, there is no way to write something like this and expect it to work:

func testExample() {
    UserDefaults.standard.set(true, forKey: "keepScreenOnKey")
}

The only way to communicate with your app from UI tests is through launchArguments and launchEnvironment. Using this method, there are three solutions for solving state problems (or at least three solutions that I know of).

Solution #1

Solution #1 is to mock UserDefaults values for certain keys. This is done by passing two new launch arguments to an array. The first argument is the key and the second one is the value. Here’s how to do it:

func testExample() {
    app.launchArguments += ["-keepScreenOnKey", "YES"]
    app.launch()
}

There are several things to note here:

  • Use the += sign to add new launchArguments, instead of the = sign which would override previously set ones.
  • Note the minus sign before the -keepScreenOnKey key. The minus sign indicates that it should take the next argument as a value for that UserDefaults key.
  • Use appropriate values for non-string keys: "YES"/"NO" for Bools, "123" for Ints and so on.

Solution #2 (if solution #1 doesn’t work)

The previous solution might not work if you’re using the SwiftyUserDefaults library. In this case, there is one elegant solution: if the application is running under UI tests, erase all UserDefaults data from the app. How does the app know if it is being run under UI tests? By passing the launch arguments message, like this:

override func setUp() {
    super.setUp()
    app.launchArguments += ["UI-Testing"]
}

Please note that I did not call the app.launch() here because I will need to add more arguments and/or environment variables later. Changes to arguments and environment variables are only counted before app launches.

To reset all UserDefaults data, I have to do it in the iOS application. The point is to check if the app is being run under UI tests before everything else happens, in AppDelegate.swift. If the app is running under UI tests, erase all data like this:

import UIKit
 
@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
 
    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool {
        setStateForUITesting()
 
        return true
    }
 
    static var isUITestingEnabled: Bool {
        get {
            return ProcessInfo.processInfo.arguments.contains("UI-Testing")
        }
    }
 
    private func setStateForUITesting() {
        if AppDelegate.isUITestingEnabled {
            UserDefaults.standard.removePersistentDomain(forName: Bundle.main.bundleIdentifier!)
        }
    }
}

Solution #3 (if solution #2 is not enough)

But what if the UI test expects states other than the default state set by solution #2? Bools default to false, Integers to 0 and Strings to "", but what if I need true for the Bool key? Just like the second and fourth test case expect the first or second switch to be turned on. This time I just need to tell my app the correct state by using launch environment key-value pairs.

By adding more than one parameter to the launch environment, I have to check the existence of every single parameter in my app. So, to make this solution scalable, I will need to add some prefix to the keys. The rest of the key is the exact UserDefaults key that I’m trying to set. By iterating every environment key in AppDelegate.swift, I only take keys with the prefix, truncate the prefix and what I get is the UserDefaults key. For that key, I set what is in the launch environment value.

I chose the UI-TestingKey_ prefix and what it looks like is here (for the test case #2 assuming setUp() method from solution #2 is used):

func testCaptionTextForTurnedOnKeepScreenOnSwitch() {
    app.launchEnvironment["UI-TestingKey_keepScreenOn"] = "YES"
    app.launch()
    XCTAssertTrue(app.staticTexts["Screen stays turned on"].exists,
           "Caption text for Keep Screen On switch is not correct on app startup.")
}

Then, in AppDelegate.swift, I check if the application is running under UI tests. If so, I want to iterate through every launch environment key that has the prefix UI-TestingKey_. When I find such keys, I truncate the prefix and what’s left is the UserDefaults key. Then, I try to check what is in the value, and based on that value I’m taking actions of setting UserDefaults values, like this:

import UIKit
 
@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
 
    var window: UIWindow?
 
    static let uiTestingKeyPrefix = "UI-TestingKey_"
 
    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool {
 
        if AppDelegate.isUITestingEnabled {
            setUserDefaults()
        }
 
        return true
    }
 
    static var isUITestingEnabled: Bool {
        get {
            return ProcessInfo.processInfo.arguments.contains("UI-Testing")
        }
    }
 
    private func setUserDefaults() {
        for (key, value)
            in ProcessInfo.processInfo.environment
            where key.hasPrefix(AppDelegate.uiTestingKeyPrefix) {
            // Truncate "UI-TestingKey_" part
            let userDefaultsKey = key.truncateUITestingKey()
            switch value {
            case "YES":
                UserDefaults.standard.set(true, forKey: userDefaultsKey)
            case "NO":
                UserDefaults.standard.set(false, forKey: userDefaultsKey)
            default:
                UserDefaults.standard.set(value, forKey: userDefaultsKey)
            }
        }
    }
}
 
extension String {
    func truncateUITestingKey() -> String {
        if let range = self.range(of: AppDelegate.uiTestingKeyPrefix) {
            let userDefaultsKey = self[range.upperBound...]
            return String(userDefaultsKey)
        }
        return self
    }
}

Please note that this example only works for Bool and String keys. If you need more scalability, the switch command should be modified to somehow check if the value is Integer or Double or Any other value, but the general idea is here.

Working examples

A working example can be found here. The Master branch is used to show the solution #3. mock-userdefaults and reset-userdefaults-data the branches are used to showcase solutions #1 and #2, respectively.

Mladen Jakovljević

Android Developer with Bachelor’s degree in Computer Science. Working at codecentric Banja Luka since May 2017.

Comment

Your email address will not be published. Required fields are marked *