Painless UI Testing in iOS (Part 1) - Mocking the Network
Table of Contents
Painless UI Testing in iOS (Part 1) - Mocking the Network
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!
In this article, I am going to walk you through 3 tips to make your UI Tests faster and more reliable! 💪
By the way, I am a loyal XCUITest soldier so, XCTest it is gonna be! ✊
Of course, those tips will also be valid for any UI Testing framework. 👍
There is no way around it, if you want to avoid flakiness during UI Testing, you have to mock the network.
Now, in iOS, there is no straightforward way of doing it. 🤔
We have to inject mocked data to the host executable at runtime, through an environment variable. 💉
But first, we need to prepare our Network layer for this kind of mocking and there are several ways of doing it. Personally, I like to protocolize the URLSession to inject a mocked implementation in my NetworkManager! 🤓
See my article Lightweight Design Patterns in iOS (Part 1) - Observer for more details on how I like to build my Network layer! 😃
Alright, alright, I’m gonna explain it to you! 😜
But first, let’s create a protocol (URLSessionProtocol) abstracting Foundation’s URLSession. 👌
import Foundation
protocol URLSessionProtocol {
func dataTask(with request: URLRequest, completionHandler: @escaping (Data?, URLResponse?, Error?) -> Void) -> URLSessionDataTaskProtocol
}
extension URLSession: URLSessionProtocol {
func dataTask(with request: URLRequest, completionHandler: @escaping (Data?, URLResponse?, Error?) -> Void) -> URLSessionDataTaskProtocol {
return dataTask(with: request, completionHandler: completionHandler) as URLSessionDataTask
}
}
Now, as you can see, we also need a protocol for the URLSessionDataTask, so we can also make a mock implementation of the data task! 😃
Right on, let’s write our URLSessionDataTaskProtocol! ✍️
import Foundation
protocol URLSessionDataTaskProtocol {
func resume()
func cancel()
func suspend()
}
extension URLSessionDataTask: URLSessionDataTaskProtocol {}
OK! 👌 That’s all the abstraction we need, for now at least. 😉
At this time, we can already inject it in our NetworkManager and see how it plays out! 👍
final class NetworkManager {
private var session: URLSessionProtocol
...
init(session: URLSessionProtocol = URLSession(configuration: .default), ...) {
self.session = session
...
}
...
func dataTask(with urlRequest: URLRequest, ...) {
session.dataTask(with: urlRequest) { ...
...
}
...
}
...
}
The really good thing about it is that, there is no “test code” in the NetworkManager, it almost looks unchanged. 💯
All we did was abstract the session with a protocol to allow a different implementation of it! 🚀
Before we get to the mocking part, we first need to define the way we are going to mock the responses. 📄
Let’s start with the Response struct! 👍
import Foundation
struct Response: Codable {
var file: File?
var error: NetworkError?
var dataTask: URLSessionDataTaskMock
init(_ file: File? = nil, error: NetworkError? = nil, dataTask: URLSessionDataTaskMock = URLSessionDataTaskMock()) {
self.file = file
self.error = error
self.dataTask = dataTask
}
}
As defined above, a Response can take a File (JSON or image) an Error and a URLSessionDataTaskMock.
Before going any further, let’s see what a File is actually about. 👀
import Foundation
final class File: Codable {
var name: String
var `extension`: Extension
enum Extension: String, Codable {
case json
case jpg
}
init(_ name: String, _ extension: Extension) {
self.name = name
self.`extension` = `extension`
}
var data: Data? {
guard
let url = Bundle(for: type(of: self)).url(forResource: name, withExtension: `extension`.rawValue),
let data = try? Data(contentsOf: url) else {
return nil
}
return data
}
}
Thus, the File is the entity where we encode the responses to be injected in the launch environment, at runtime. 📁
Nice! 😎
Now we have the mock Responses as well as the protocols ready, mocking is very easy. 😃
All we need is making our own implementation of URLSessionProtocol and URLSessionDataTaskProtocol for testing purposes! 💯
But first, let’s define our Request enum! ✅
enum Request: Hashable {
case fetchSomething
var url: String {
switch self {
case .fetchSomething:
return "https://api.com/something"
}
}
var method: HTTPMethod {
switch self {
case .fetchSomething
return .get
}
}
var parameters: Parameters {
switch self {
case .fetchSomething:
return .url(["api-key": "abcd1234"])
}
}
}
Now, because we are going to use the Request as key to our responses Dictionary, we are going to map it to an absolute URL. 🗺
For this purpose, let’s first create an extension on Parameters for getting a String representation of a query out of it. 👌
extension Parameters {
var query: String? {
switch self {
case .url(let url):
guard !url.isEmpty else {
return nil
}
return url
.map { key, value in
guard let value = value else {
return key
}
return "\(key)=\(value)"
}.joined(separator: "&")
default:
return nil
}
}
}
And now we’ll create an extension on Request to get a String representation of an absolute URL from it. 👍
extension Request {
var absoluteUrl: String {
guard let query = parameters.query else {
return url
}
return "\(url)?\(query)"
}
}
Alright, we are good to go! 🎉
Let’s get started with the URLSessionMock then! 💪
All it needs is a mutable Dictionary of Requests (keys) and Responses (values) as well as conforming to our URLSessionProtocol. 👌
The Responses Array inside the Dictionary will behave like a Queue (FIFO — First In First Out), meaning that they will be consumed just like a real network would! 😮 Simple! 🙌
import Foundation
final class URLSessionMock: Codable {
private(set) var responses: [String: [Response]]
init(responses: [Request: [Response]] = [:]) {
self.responses = Dictionary( uniqueKeysWithValues:
responses.map { request, responses in
return (key: request.absoluteUrl, value: responses.reversed())
}
)
}
}
extension URLSessionMock: URLSessionProtocol {
func dataTask(with request: URLRequest, completionHandler: @escaping (Data?, URLResponse?, Error?) -> Void) -> URLSessionDataTaskProtocol {
guard
let url = request.url?.absoluteString,
let response = responses[url]?.popLast() else {
completionHandler(nil, nil, NetworkError.invalidRequest)
return URLSessionDataTaskMock()
}
completionHandler(response.file?.data, nil, response.error)
return response.dataTask
}
}
With this implementation, we have complete control over the requests being made. 👮
We can decide which Response to return depending on the Request and even stub on the data task! 🤯
Speaking of which, here is the mock implementation of the URLSessionDataTaskProtocol. 😉
final class URLSessionDataTaskMock: Codable {
private(set) var isResumed = false
}
extension URLSessionDataTaskMock: URLSessionDataTaskProtocol {
func cancel() {
isResumed = false
}
func suspend() {
isResumed = false
}
func resume() {
isResumed = true
}
}
Almost there! ☝️
Before writing our tests, let’s add some helper functions/variables! 👍
To avoid making use of hardcoded strings, let’s create an Identifiable protocol which will provide an identifier to any class that conforms to it! 💪
That way, when injecting the URLSessionMock in the launch environment dictionary, we can use the identifier variable as the key. 🔑
import Foundation
protocol Identifiable {
static var identifier: String { get }
}
extension Identifiable {
static var identifier: String {
return String(describing: self)
}
}
extension URLSessionMock: Identifiable {}
And finally, here are some small extension functions/variables for encoding/decoding our mock from the environment! 💯
Let’s start with an extension on ProcessInfo to decode any Identifiable…
import Foundation
extension ProcessInfo {
func decode<T: Identifiable & Decodable>(_: T.Type) -> T? {
guard
let environment = environment[T.identifier],
let codable = T.decode(from: environment) else {
return nil
}
return codable
}
}
Then, make an extension on Decodable to decode from any String…
import Foundation
extension Decodable {
static func decode(from json: String) -> Self? {
guard let data = json.data(using: .utf8) else {
return nil
}
return try? JSONDecoder().decode(self, from: data)
}
}
Lastly, write an extension on Encodable to encode to either String or Data! ✅
import Foundation
extension Encodable {
var json: String? {
guard let data = data else {
return nil
}
return String(data: data, encoding: .utf8)
}
var data: Data? {
return try? JSONEncoder().encode(self)
}
}
Finally! 🏁
We are done with preparations! 🎉
Now, we need to inject the URLSessionMock in the launch environment so it can be received in the AppDelegate! 💉
Let’s create a nice extension function on XCUIApplication to help us achieve that. 💪
import XCTest
extension XCUIApplication {
func launch(with sessionMock: URLSessionMock = URLSessionMock()) {
launchEnvironment[URLSessionMock.identifier] = sessionMock.json
launch()
}
}
And last but not least, the AppDelegate is now able to decode the URLSessionMock from the ProcessInfo and pass it to the NetworkManager! 👌
Yes, that’s all. 😎
import UIKit
@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
func application(_ application: UIApplication, willFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
let session: URLSessionProtocol = ProcessInfo.processInfo.decode(URLSessionMock.self) ?? URLSession(configuration: .default)
let networkManager = NetworkManager(session: session)
...
return true
}
}
All set! ✊
Now, look how simple and elegant UI testing is! 😮
import XCTest
final class SomethingUITest: XCTestCase {
private lazy var app = XCUIApplication()
override func setUp() {
super.setUp()
...
}
func testSomething() {
let sessionMock = URLSessionMock(
responses: [
.fetchSomething: [
Response(File("something_response", .json)),
Response(error: .invalidResponse)
]
]
)
app.launch(with: sessionMock)
...
}
...
}
Boom! 💥
The Network layer is now completely under our control and can be mocked with the snap of a finger! 👍
We can specify at runtime, on a per-test basis, which Responses to return, just by defining a Dictionary of Requests and Responses. 🗃
Was it worth it? Did that help you overcome the problems of UI Testing, starting with Mocking the Network? 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 2 of my series Painless UI Testing in iOS!
Bye Bye! 👋