Triggering Events while UI Testing
Overview
In this post, I’ll be sharing two techniques to trigger events in your application while performing UI tests in XCTest
without needing to do so via the UI.
Why?
My previous posts on this topic go over a slightly different style of UI testing that is not exactly full integration testing, but rather a smaller subset of it using mocks and stubs. This allows testing the UI without needing to ever hit the network or navigate through a labyrinth of steps to reach a particular testing check point. Triggering events without using the UI help further these goals.
Example
Taking our example from last time (a login screen backed by a view model). Suppose we now would like to add a welcome prompt to greet first time users.
protocol LoginViewModel {
var showWelcomePrompt: (() -> Void)? { get set }
...
}
The login view controller displays an alert controller with a welcome message when told so by it’s view model:
class LoginViewController: UIViewController {
var viewModel: LoginViewModel?
...
override func viewDidLoad() {
super.viewDidLoad()
// bind to view model events
//
// best to use an FRP framework like RxSwift, SwiftBond etc...
// for demo purposes a simple closure will suffice
viewModel?.showWelcomePrompt = { [weak self] in
let alert = UIAlertController(title: "Welcome", message: "Welcome to this awesome app!", preferredStyle: .Alert)
let close = UIAlertAction(title: "Close", style: .Cancel, handler: nil)
alert.addAction(close)
self?.showViewController(alert, sender: self)
}
}
...
}
The logic of when best to display the dialog based on various factors can be easily unit tested in the view model (see the sample GitHub project for an example). In addition, it would be great to have a UI test that verifies the login screen is in fact displaying this welcome message appropriately. In this example, gaining the ability to trigger events that aren’t necessarily linked to UI components like a button would make UI testing much simpler.
Establishing Communication
Before we dive in, here’s a quick recap on how UI testing works on iOS. The UI testing code is run in a separate target (it actually is a separate app) that spins up your main application target and interacts with it as a user would, using the UI. Out of the box there isn’t any other form of communication available post launch.
The test target is a full fledged application, it has access to the same APIs available to any normal application. As such building something to establish communication is fairly possible.
Technique #1: Darwin Notifications
The first technique I’d like to cover is inspired by the MMWormhole project and is hidden deep within CoreFoundation
.
Have you ever heard of CFNotificationCenter
? more specifically DarwinNotifyCenter
? turns out this low level API allows sending notifications between applications! that’s right … between applications!! Before we all get too excited, there is a caveat and it’s a big one. Notifications sent from DarwinNotifyCenter
can’t contain any additional data (such as a userInfo
dictionary like in NSNotifcationCenter
).
That being said, even in this restrictive form, for the purposes of triggering events in the main application from a UI test it is still quite valuable. As such a basic “Beeper
” utility can be created. Let’s sketch that up first with a protocol to see how it can be leveraged
typealias BeepHandler = () -> Void
protocol Beeper {
func beep(identifier: String)
func register(identifier: String, handler: BeepHandler)
func unregister(identifier: String)
}
Continuing with our example, we can configure the app to use a testing stub view model that will trigger the welcome prompt event upon receiving a “beep” with the appropriate identifier.
protocol LoginViewModel {
var showWelcomePrompt: (() -> Void)? { get set }
// ...
}
class TestingLoginViewModel: LoginViewModel {
var showWelcomePrompt: (() -> Void)? = nil
let beeper: Beeper = DarwinNotificationCenterBeeper()
init() {
beeper.register(BeeperConstants.triggerWelcomePrompt) { [unowned self] in
self.showWelcomePrompt?()
}
}
...
}
The UI test can then be written:
func testWelcomePrompt() {
launch(usingTestingViewModel: true)
let alert = app.alerts["Welcome"]
XCTAssertFalse(alert.exists)
beeper.beep(identifier: BeeperConstants.triggerWelcomePrompt)
XCTAssertTrue(alert.waitForExistence(timeout: 2))
alert.buttons["Close"].tap()
XCTAssertFalse(alert.exists)
}
As for the concrete implementation of the Beeper
:
class DarwinNotificationCenterBeeper: Beeper {
private let darwinNotificationCenter: CFNotificationCenter
private let prefix: String
private var handlers = [String: BeepHandler]()
init(prefix: String = "net.mxpr.utils.beeper") {
darwinNotificationCenter = CFNotificationCenterGetDarwinNotifyCenter()
self.prefix = prefix.appending(".")
}
deinit {
CFNotificationCenterRemoveObserver(darwinNotificationCenter,
rawPointerToSelf,
nil,
nil)
}
private func notificationName(from identifier: String) -> String {
return "\(prefix)\(identifier)"
}
private func identifier(from name: String) -> String {
guard let prefixRange = name.range(of: prefix) else {
return name
}
return String(name[prefixRange.upperBound...])
}
fileprivate func handleNotification(name: String) {
let handlerIdentifier = identifier(from: name)
if let handler = handlers[handlerIdentifier] {
handler()
}
}
private var rawPointerToSelf: UnsafeRawPointer {
return UnsafeRawPointer(Unmanaged.passUnretained(self).toOpaque())
}
// MARK: - Beeper
func beep(identifier: String) {
let name = notificationName(from: identifier)
CFNotificationCenterPostNotification(darwinNotificationCenter,
CFNotificationName(name as CFString),
nil,
nil,
true)
}
func register(identifier: String, handler: @escaping BeepHandler) {
handlers[identifier] = handler
let name = notificationName(from: identifier)
CFNotificationCenterAddObserver(darwinNotificationCenter,
rawPointerToSelf,
handleDarwinNotification,
name as CFString,
nil,
.deliverImmediately)
}
func unregister(identifier: String) {
handlers[identifier] = nil
let name = notificationName(from: identifier)
let cfNotificationName = CFNotificationName(name as CFString)
CFNotificationCenterRemoveObserver(darwinNotificationCenter,
rawPointerToSelf,
cfNotificationName,
nil)
}
}
fileprivate func handleDarwinNotification(notificationCenteR: CFNotificationCenter?,
observer: UnsafeMutableRawPointer?,
notificationName: CFNotificationName?,
unusedObject: UnsafeRawPointer?,
unusedUserInfo: CFDictionary?) -> Void {
guard let observer = observer,
let notificationName = notificationName else {
return
}
let beeper = Unmanaged<DarwinNotificationCenterBeeper>.fromOpaque(observer).takeUnretainedValue()
let name = (notificationName.rawValue as String)
beeper.handleNotification(name: name)
}
A sample project containing all the sources can be found on GitHub.
Technique #2: HTTP Server
A far more superior technique involves setting up a local http server to run in your main application (when testing). This will allow the main application to receive requests to manipulate its state and even send back information to the test target in the response.
A local http server can be easily setup using open source frameworks like Swifter. The method previously described can be followed to achieve similar advances in testing.
One fairly extensive adaptation of this technique can be seen in SBTUITestTunnel by @tomascamin. It includes various helpers and utilities geared specifically at UI Testing.
Conclusion
UI Testing is awesome, and can be more awesome once you find new ways to leverage it. I’ll add my usual disclaimer, UI testing isn’t a replacement to unit testing, it’s just another tool in our toolbox and should be used where appropriate.
Happy Testing!
Links
Related
- UI Testing with NSUserDefaults
- Testing analytics on iOS with XCTest
- What’s really new with UI Testing in iOS 10
Update (25th-Nov-2018): Code snippets updated for Swift 4.2 syntax