UI Testing with NSUserDefaults
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.
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