Painless UI Testing in iOS (Part 2) - Stubbing the Navigation
Table of Contents
Painless UI Testing in iOS (Part 2) - Stubbing the Navigation
Table of Contents
Let’s be realistic, UI Testing is a pain. For everybody. And with everybody, I include both Android and iOS developers. The main reason for it is that we’ve all come to this point when one-tenth of the UI Tests fail, randomly, for no definite reason. Could be the CI Virtual Machine which was slower than usual, or a small latency in the emulator… Don’t get me started with execution time… We’ve all been through that.
Well, guess what!
The time has come to make UI Testing great again.
🎬 Hi there, I’m Jean!
Previously on the series Painless UI Testing in iOS, we have walked through Mocking the Network and how elegant UI Testing becomes with it. 😍
In this episode, we are about to cover Stubbing the Navigation, using Coordinators. 🚦
If you are not familiar with the Coordinator Pattern, I invite you to check out my article Lightweight Design Patterns in iOS (Part 3) - Coordinator! 😃
In Mobile Development, UI tests are known for being very slow. But in Android, UI tests are actually faster than in iOS and thus for one reason: In Android, UI tests launch a specific screen but in iOS, UI tests launch the whole Application.
This is a deal-breaker for iOS developers, as we can not afford to go through the whole user flow every time we run a UI test. 🙅♂
Consequently, UI tests become one single massive UI test which, at the very beginning, works but as the features are piling up doesn’t scale, becomes hard to maintain and eventually gets removed… 😢
Don’t you worry friend, this time has come to an end! ✊
Let me introduce you…
🥁
…to Stubbing the Navigation using Coordinators! 🎉
Alright, let’s get right to the point with a concrete example. 👌
We have an app, which displays a list of items and tapping on one would open a new screen giving more details about this item. Simple. 👍
So, let’s write our Coordinator then! ✍️
import UIKit
protocol CoordinatorProtocol: class {
func start()
func open(_ something: Something)
...
}
final class Coordinator {
private let window: UIWindow
...
let navigationController = UINavigationController()
init(window: UIWindow, ...) {
self.window = window
self.window.rootViewController = navigationController
self.window.makeKeyAndVisible()
...
}
...
}
extension Coordinator: CoordinatorProtocol {
func start() {
...
}
func open(_ something: Something) {
...
}
...
}
No surprise, just a standard Coordinator like you know it already! 😃
Now, looking at our AppDelegate, instantiating and starting our Coordinator is also quite straightforward. 👌
import UIKit
@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
var coordinator: CoordinatorProtocol!
func application(_ application: UIApplication, willFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
...
let window = UIWindow(frame: UIScreen.main.bounds)
coordinator = Coordinator(window: window, ...)
return true
}
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
coordinator.start()
return true
}
}
Again, nothing new, all clear! ✅
Now thinking once more about our problem with UI Testing, it becomes very clear that our course of action must happen in the AppDelegate. 👍
What we are trying to achieve here, is being able to jump on the Detail screen directly, without needing to manually tap on a cell in the first screen. 🤯
And a very simple approach to that would be, to simply tell our Coordinator to directly open the desired item! 💡
Now how are we going to do that? 🤔
Well, as we did with Mocking the Network, let’s pass that information through the launch environment and decide, at runtime in the AppDelegate, which method to call in our Coordinator! 😃
And all we need for that is an enum! 😆
import Foundation
enum CoordinatorStub: Identifiable {
case start
case openSomething(Something)
}
That’s it, that’s our stub. 😮
Now, let’s make it Codable so we can inject it in the ProcessInfo! 💉
import Foundation
extension CoordinatorStub: Codable {
enum CodingKeys: String, CodingKey { case something }
init(from decoder: Decoder) throws {
let values = try decoder.container(keyedBy: CodingKeys.self)
if let something = try? values.decode(Something.self, forKey: .something) {
self = .openSomething(something)
return
}
self = .start
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
switch self {
case .start:
break
case .openSomething(let something):
try container.encode(something, forKey: .something)
}
}
}
Alright, we’re good to go! 🚶
Now, let’s improve our XCUIApplication extension to include the CoordinatorStub in the launch environment! 👍
import XCTest
extension XCUIApplication {
func launch(_ coordinatorStub: CoordinatorStub, with sessionMock: URLSessionMock = URLSessionMock()) {
launchEnvironment[CoordinatorStub.identifier] = coordinatorStub.json
launchEnvironment[URLSessionMock.identifier] = sessionMock.json
launch()
}
}
The last piece of the puzzle: decode the CoordinatorStub inside the AppDelegate and call the desired method in the Coordinator! 👌
import UIKit
@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
var coordinator: CoordinatorProtocol!
func application(_ application: UIApplication, willFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
...
let window = UIWindow(frame: UIScreen.main.bounds)
coordinator = Coordinator(window: window, ...)
return true
}
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
guard let coordinatorStub = ProcessInfo.processInfo.decode(CoordinatorStub.self) else {
coordinator.start()
return true
}
switch coordinatorStub {
case .start:
coordinator.start()
case .openSomething(let something):
coordinator.open(something)
}
return true
}
}
Smooth! 😍
Now let’s take a closer look at our UI tests and see how it plays out! 👀
import XCTest
final class SomethingUITest: XCTestCase {
private lazy var app = XCUIApplication()
override func setUp() {
super.setUp()
...
}
func testStart() {
let sessionMock = URLSessionMock(
responses: [
.fetchSomething: [
Response(File("something_response", .json)),
Response(error: .invalidResponse)
]
]
)
app.launch(.start, with: sessionMock)
...
}
func testOpenSomething() {
let something = Something(stuff: "Stuff", ...)
let sessionMock = URLSessionMock(
responses: [
.fetchSomething: [
Response(File("something_response", .json)),
Response(error: .invalidResponse)
]
]
)
app.launch(.openSomething(something), with: sessionMock)
...
}
}
Boom! 💥
Fast, robust and readable, what else do you need? 😎
Not only we have full control over the Network, but now also the Navigation is in our grip! 👊
We can specify at launch, on a per-test basis, which screen to jump in, just by using a simple enum! 🚀
Was it worth it? Did that help you overcome the problems of UI Testing, continuing with Stubbing the Navigation? Are you now feeling more confident about writing UI Tests?
If so, follow me on Twitter, I’ll be happy to answer any of your questions and you’ll be the first one to know when a new article comes out! 👌
If you’d like to see a real-life example of an app doing extensive UI Testing, don’t hesitate to check out my Github repo! 📦
See you next week, for Part 3 of my series Painless UI Testing in iOS!
Bye Bye! 👋