How to prepare your iOS app for UI testing by@mgazar
4,280 reads

How to prepare your iOS app for UI testing

Read on Terminal Reader

Too Long; Didn't Read


Companies Mentioned

Mention Thumbnail
Mention Thumbnail
featured image - How to prepare your iOS app for UI testing
Mostafa Gazar HackerNoon profile picture

@mgazar

Mostafa Gazar

Android Pro, built million-downloads app, YC alumni. I write...

About @mgazar
LEARN MORE ABOUT @MGAZAR'S EXPERTISE AND PLACE ON THE INTERNET.
react to story with heart

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**.

image

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 J_SON_}

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](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 Quickimport 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.

Mostafa Gazar HackerNoon profile picture
by Mostafa Gazar @mgazar.Android Pro, built million-downloads app, YC alumni. I write about Machine Learning and Mobile.
Read My Stories

RELATED STORIES

L O A D I N G
. . . comments & more!
Hackernoon hq - po box 2206, edwards, colorado 81632, usa