Overview

Last year at WWDC’15, Apple introduced UI testing to XCTest. While not a completely new concept, the fact its now a first class citizen in the developer toolchain is a big deal.

Having used it on a few occasions recently, here’s some tidbits I thought I’d share.

UI Tests

When unit testing, the approach is usually spinning up a single class to test and mock all it’s dependancies to control it’s behavior. This allows us to exercise and verify all the class’s functionality.

Unlike unit tests, when using UI tests, there is no direct way to spin up an instance of a single view to be tested and provide it with mocks. The UI test target is actually a completely separate process, as such the main application code can’t be communicated with directly either.

Arguably that is not how a UI Test should be used, but rather it’s meant to ensure the flows and behavior of the application work as a user would expect.

That being said, tests should be made as reliable and reproducible as possible. Especially if your app requires performing any network requests or accessing any external resources. Not only would the UI tests be slower but also the test environment itself needs to have the appropriate access to these resources which may not always be available. As such there is merit in being able to control certain aspects of the applications when doing UI tests.

Launch Arguments

Googling around, you will find many examples that leverage XCUIApplication().launchArguments to pass in arguments to the main target being tested.

e.g.

MyAppUITest.swift

override func setUp() {
  super.setUp()

  let app = XCUIApplication()
  app.launchArguments.append("ui-testing")
  app.launch()
}

MyAppDelegate.swift

func setupModel() -> Model {
    let uiTesting = ProcessInfo.processInfo.arguments.contains("ui-testing")
    
    let model: Model = uiTesting ? StubModel() : NetworkModel()
    
    return model
}

NSUserDefaults

We can achieve a similar result by leveraging NSUserDefaults too.

Reading though the Defaults System Guide, there’s a way to manipulate the NSUserDefaults’s Argument Domain through launch arguments.

This is achieved through specifying launch arguments in the following format:

-<key> <value>

Taking our example above it can be changed to:

MyAppUITest.swift

override func setUp() {
    super.setUp()
    
    let app = XCUIApplication()
    app.launchArguments.append(contentsOf: ["-ui-testing", "YES"])
    app.launch()
}

MyAppDelegate.swift

func setupModel() -> Model {
    let defaults = UserDefaults.standard
    let uiTesting = defaults.bool(forKey: "ui-testing")
    
    let model: Model = uiTesting ? StubModel() : NetworkModel()
    
    return model
}

NSUserDefault Stubs

Taking the concept above we can take it a step further and start creating full fledged stubs that are controlled by user defaults. This can allow us to gain even more control over our application from the UI tests.

Here’s a simple example of an application with a login feature:

LoginUITest.swift

struct Credentials {
  let user: String
  let password: String
}

// ...

let credentials = Credentials(user: "test", password: "1234")

override func setUp() {
    super.setUp()
    
    let app = XCUIApplication()
    app.launchArguments.append(contentsOf: ["-ui-testing", "YES"])
    app.launchArguments.append(contentsOf: ["-model.user", credentials.user])
    app.launchArguments.append(contentsOf: ["-model.password", credentials.password])
    app.launch()
}

func testLoginSuccessful() {
  let app = XCUIApplication()

  let userField = app.textFields["Username"]
  let passwordField = app.secureTextFields["Password"]

  userField.tap()
  userField.typeText(credentials.user)

  passwordField.tap()
  passwordField.typeText(credentials.password)

  app.buttons["Login"].tap()

  let results = app.staticTexts["results"]
  XCTAssertEqual(results.label, "Success")
}

func testLoginUnsuccessful() {
  let app = XCUIApplication()

  let userField = app.textFields["Username"]
  let passwordField = app.secureTextFields["Password"]

  userField.tap()
  userField.typeText(credentials.user)

  passwordField.tap()
  passwordField.typeText("incorrect-password")

  app.buttons["Login"].tap()

  let results = app.staticTexts["results"]
  XCTAssertEqual(results.label, "Login failed")
}

MyAppDelegate.swift

...
func setupModel() -> Model {
  let defaults = UserDefaults.standard
  let uiTesting = defaults.bool(forKey: "ui-testing")
  
  let model: Model = uiTesting ? UserDefaultsModel(defaults: defaults) : NetworkModel()
  
  return model
}
...

Model.swift

protocol Model {
  func login(user: String, password: String) -> Bool
}

UserDefaultsModel.swift

class UserDefaultsModel {
    let defaults: UserDefaults
    init(defaults: UserDefaults) {
        self.defaults = defaults
    }
    
    // MARK: - Model
    func login(user: String, password: String) -> Bool {
        if let validUser = defaults.string(forKey: "model.user"),
            let validPassword = defaults.string(forKey: "model.password"),
            validUser == user,
            validPassword == password {
            return true
        }
        return false
    }
}

What we achieve here is the ability to test our UI without making a single network request! Another win we gain by following this approach is the ability to exercise situations that are hard to reproduce in a live system. For example if our model also reported connectivity failures, we have a simple way to simulate and test that, without actually disconnecting the machine from the network.

Schemes

Sometimes there may still be a desire to use the full application without making using any stubs when conducting UI testing for one reason or another.

In our login example above, if we want to test the login using the network model we have to ensure the credentials provided are valid ones (perhaps for a test account). In some cases, burning the credentials in the code and have it visible in the repository may not be desired.

Once again we can use launch arguments and NSUserDefault, this time via schemes.

Testing Scheme

A new testing scheme can be created with the credentials added as launch arguments:

-testing.user testAccount -testing.user testPassword

Ensure the testing scheme shared option is not checked and that the scheme itself is not checked into the repository.

In the test, the credentials can then be extracted from user defaults:

var credentials: Credentials!

override func setUp() {
  super.setUp()

  let defaults = UserDefaults.standard
  
  if let user = defaults.string(forKey: "test.user"),
      let password = defaults.string(forKey: "test.password") {
      credentials = Credentials(user: user, password: password)
  } else {
      XCTFail("No credentials provided for testing")
  }
  
  let app = XCUIApplication()
  app.launch()
}

Final Thoughts

UI testing is by no means a replacement to unit testing. Each have their uses and there’s plenty of material online as to which is more suited to different situations.

As with unit testing, good architecture and design can go a long way to making the application testable, even from a UI level.

i.e. No singletons please!, pass your dependencies in!!

Happy testing!

Links

Update (25th-Nov-2018): Code snippets updated for Swift 4.2 syntax