Mostafa Gazar

@mgazar

How to prepare your iOS app for UI testing

Write stable and consistent UI tests for your iOS app

I worked closely with our tester last year to add the first UI tests target to our iOS project. We had some simple goals to measure our success:

  • Writing easy to maintain tests.
  • Abstract the app internal wiring and architecture from the tests.
  • An easy mechanism to stub relevant API calls (find the relevant calls using Charles or Charles Proxy).

What are you actually testing

To make sure that you are testing your app and not your backend for example, it is a good idea to stub all your API calls. This also guarantees consistency, stable and less flaky UI tests.

Your aim should not be to test your backend or the integration between it and the frontend but rather to test the app in isolation and confirm that it works consistently based on some predetriment conditions.

Setting things up

If your app doesn’t already have a UI testing target, you will have to add one by going to File > New > Target > iOS UI Testing Bundle.

How XCTest framework works

The first thing that trips most people is that you cannot stub your API calls in your XCTestCase, because it just opens your app so any stubbing you do there will be lost.

You have to use launch arguments instead and let your app knows that it is in UI test mode and what API calls need stubbing.

How to stub your API calls

When it comes to returning fake data (predetriment responses) to your app you have a bunch of options like mocking repositories (managers, etc.. based on your app architecture) protocols that should make API calls. In other words circumventing the call before it actually hit the network layer.

Another option is stubbing all the web calls that your app might make during a test. And with OHHTTPStubs your app actually execute all the code in your repository and when you hit the network layer OHHTTPStubs returns a predetriment response or an error. Unlike the first option this can actually expose hidden errors in your repository.

An easy structure to stub existing and future API calls

In my previous attempts to write maintainable UI tests mostly on Android, mocking repository methods has always been tricky specially from the tester point of view because they do not get to work on the codebase on daily basis. On the other hand you can easily run the app and find out all the requests going out of the app and their responses and easily stub them without having to worry about what is going on in the code.

I still wanted to make the code clear and somewhat easily mappable to our API structure/documentations. Any REST backend has different APIs and each API has a bunch of endpoints so that is exactly how the code looks like. I might even look at auto-generating all this code in the future.

protocol StubApi {   
}
protocol StubEndpoint {

var path: String { get }
var name: String { get }
var type: String { get }
var method: HttpMethod { get }
var params: [String: String]? { get }

}
extension StubEndpoint {

// Helper serialization function.
func toDictionary() -> [String: String] {
var dictionary = [String: String]()
dictionary["path"] = path
dictionary["name"] = name
dictionary["type"] = type
dictionary["method"] = method.rawValue

if let params = params {
let json = try! JSONSerialization.data(withJSONObject: params, options: JSONSerialization.WritingOptions())
dictionary["params"] = String(data: json, encoding: String.Encoding(rawValue: String.Encoding.utf8.rawValue))
}

return dictionary
}

}

I also included the HTTP methods and resource types that the StubbingManager would support.

enum HttpMethod: String {
case GET, POST, DELETE, PUT

static func fromString(rawValue: String?) -> HttpMethod {
guard let rawValue = rawValue else { fatalError() }

switch rawValue {
case HttpMethod.GET.rawValue:
return .GET
case HttpMethod.POST.rawValue:
return .POST
case HttpMethod.DELETE.rawValue:
return .DELETE
case HttpMethod.PUT.rawValue:
return .PUT
default:
fatalError()
}
}
}
enum ResourceType: String {
case JSON
}

An example API stub would look something like

// https://jsonplaceholder.typicode.com/
class PostsStubApi: StubApi {

class RetrieveOnePostItem: StubEndpoint {
let path = "/posts/1"
// This file name, it can exist anywhere but it needs to be accessible by the app target as well as the UI test target.
let name = "stub_discarded_jobs"
let type = ResourceType.json.rawValue
let method = HttpMethod.GET
let params: [String : String]? = nil
}
}

How to launch your app in UI test mode

Because you cannot stub your API calls in your tests, you will have to pass some data to your app when it is launched from a UI test. We can just use launchEnvironment on XCUIApplication for that.

class BaseTestCase: QuickSpec { // Or XCTestCase

private var stubManager: StubManager = StubManager()

// Easily add a new stub before your test run.
func stub(endpoint: StubEndpoint) {
stubManager.add(stub: endpoint)
}
    // Remove all tests after a test has ran.
func removeAllStubs() {
stubManager.removeAllStubs();
}
    // Here is where the magic happens.
func launch(app: XCUIApplication) {
app.launchEnvironment["stubs"] = stubManager.toJSON()
app.launch()
}

}

And the StubManager can live in either the app or the UI tests target, it just has to be shared between both target.

import OHHTTPStubs

final class StubManager {

let jsonHeaders = ["content-type": "application/json"]

private var stubs: [StubEndpoint] = []

deinit {
killStubs()
}

func add(stub: StubEndpoint) {
stubs.append(stub)
}

func loadStubs() {
// Just stub any image request to avoid any web calls.
stub(condition: isExtension("png") || isExtension("jpg") || isExtension("gif")) { _ in
let
stubPath = OHPathForFile("stub.jpg", type(of: self))
return fixture(filePath: stubPath!, headers: ["Content-Type" as NSObject:"image/jpeg" as AnyObject])
}
        // Now let us go through the stubs array and apply them.
for stub in stubs {
// Base url for your endpoints.
var condition = isHost("https://jsonplaceholder.typicode.com")
condition = condition && isPath(stub.path)

switch(stub.method) {
case .GET: condition = condition && isMethodGET()
case .POST: condition = condition && isMethodPOST()
case .DELETE: condition = condition && isMethodDELETE()
case .PUT: condition = condition && isMethodPUT()
}

if let params = stub.params {
condition = condition && containsQueryParams(params)
}

stub(condition: condition) { _ in
let
bundle = Bundle(for: type(of: self))
let path = bundle.path(forResource: stub.name, ofType: stub.type)
return OHHTTPStubsResponse(fileAtPath: path!, statusCode: 200, headers: self.jsonHeaders)
}
}
}

func removeAllStubs() {
stubs = []
}

func killStubs() {
OHHTTPStubs.removeAllStubs()
}
    // To serialize stubs and send them to the app.
func toJSON() -> String? {
var arrayOfStubs = [[String: String]]()

for sutb in stubs {
arrayOfStubs.append(stub.toDictionary())
}

let json = try! JSONSerialization.data(withJSONObject: arrayOfStubs, options: JSONSerialization.WritingOptions())

return String(data: json, encoding: String.Encoding(rawValue: String.Encoding.utf8.rawValue))
}
    // To deserialize stubs in the app and apply them.
func fromJSON(json: String) {
stubs.removeAll()

let data = try! JSONSerialization.jsonObject(with: json.data(using: String.Encoding.utf8)!, options: JSONSerialization.ReadingOptions())

if let stubData = data as? [[String: String]] {
for stub in stubData {
stubs.append(AnyStubEndpoint(dictionary: stub))
}
}
}

struct AnyStubEndpoint: StubEndpoint {

var path: String
var name: String
var type: String
var method: HttpMethod
var params: [String: String]?

init(dictionary: [String: String]) {
uri = dictionary["path"] ?? ""
name = dictionary["name"] ?? ""
type = dictionary["type"] ?? ""
method = HttpMethod.fromString(rawValue: dictionary["method"])

if let params = dictionary["params"] {
self.params = try! JSONSerialization.jsonObject(with: params.data(using: String.Encoding.utf8)!, options: JSONSerialization.ReadingOptions()) as? [String: String]
}
}
}
}

What’s left is just checking in the AppDelegate if launchEnvironment was set.

extension AppDelegate {

func isStubbing() -> Bool {
return ProcessInfo.processInfo.environment["stubs"] != nil
}

func configureStubsIfNeeded() -> Bool {
if isStubbing() {
stubManager = StubManager()
if let json = ProcessInfo.processInfo.environment["stubs"] {
stubManager.fromJSON(json: json)
stubManager.loadStubs()
}

return true
}
else {
guard stubManager != nil else { return false }
stubManager.killStubs()

return false
}
}

}

Write your first UI test using Quick and Nimble

Writing UI tests does not require much knowledge of the app architecture, a tester or a developer just need to find which endpoints need stubbing and can start adding more tests easily.

import Quick
import Nimble

class DiscardJobsSpec: TMTestCase {

override func spec() {
let app = XCUIApplication()

describe("retrieve discarded jobs") {

beforeEach {
stub(endpoint: PostsStubApi.RetrieveOnePostItem())
}

afterEach {
removeAllStubs()
}

it("should do something") {
// You will have one item on the screen at this point 🎉
}
}
}
}

I hope you enjoyed reading this post, share it if you like it, follow me on Twitter if you love it.

More by Mostafa Gazar

Topics of interest

More Related Stories