//

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

7.6.2018 | 7 minutes of reading time

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.

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:

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

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:

1func testExample() {
2    app.launchArguments += ["-keepScreenOnKey", "YES"]
3    app.launch()
4}
5

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:

1override func setUp() {
2    super.setUp()
3    app.launchArguments += ["UI-Testing"]
4}
5

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:

1import UIKit
2 
3@UIApplicationMain
4class AppDelegate: UIResponder, UIApplicationDelegate {
5 
6    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool {
7        setStateForUITesting()
8 
9        return true
10    }
11 
12    static var isUITestingEnabled: Bool {
13        get {
14            return ProcessInfo.processInfo.arguments.contains("UI-Testing")
15        }
16    }
17 
18    private func setStateForUITesting() {
19        if AppDelegate.isUITestingEnabled {
20            UserDefaults.standard.removePersistentDomain(forName: Bundle.main.bundleIdentifier!)
21        }
22    }
23}
24

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):

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

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:

1import UIKit
2 
3@UIApplicationMain
4class AppDelegate: UIResponder, UIApplicationDelegate {
5 
6    var window: UIWindow?
7 
8    static let uiTestingKeyPrefix = "UI-TestingKey_"
9 
10    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool {
11 
12        if AppDelegate.isUITestingEnabled {
13            setUserDefaults()
14        }
15 
16        return true
17    }
18 
19    static var isUITestingEnabled: Bool {
20        get {
21            return ProcessInfo.processInfo.arguments.contains("UI-Testing")
22        }
23    }
24 
25    private func setUserDefaults() {
26        for (key, value)
27            in ProcessInfo.processInfo.environment
28            where key.hasPrefix(AppDelegate.uiTestingKeyPrefix) {
29            // Truncate "UI-TestingKey_" part
30            let userDefaultsKey = key.truncateUITestingKey()
31            switch value {
32            case "YES":
33                UserDefaults.standard.set(true, forKey: userDefaultsKey)
34            case "NO":
35                UserDefaults.standard.set(false, forKey: userDefaultsKey)
36            default:
37                UserDefaults.standard.set(value, forKey: userDefaultsKey)
38            }
39        }
40    }
41}
42 
43extension String {
44    func truncateUITestingKey() -> String {
45        if let range = self.range(of: AppDelegate.uiTestingKeyPrefix) {
46            let userDefaultsKey = self[range.upperBound...]
47            return String(userDefaultsKey)
48        }
49        return self
50    }
51}
52

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.

share post

Likes

0

//

More articles in this subject area\n

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.