Harry Bloom

@harrybloom18

Testing our iOS networking layer

Why?

Over the past week, I have been refactoring our networking layer here at WeVat. The unit test coverage for the error scenarios that may arise from the network was pretty low, so I thought that it was a good opportunity to cast a bigger net and assure that our network refactor didn’t introduce any pesky 🐞, 🕷 or 🐜s.

Below I will briefly explain the unit test suite we are using, and will then move onto how we are dynamically testing multiple error scenarios without writing lots of code.

Approach

OHHTTPStubs

I have used OHTTPStubs on a previous project, and it seems to be the industry standard as a network stubbing library for iOS. It provides a suite of tools to help stub out any network calls that are made using NSURLConnection, NSURLSession or Alamofire, so works great for our needs here.

I have also created a small wrapper around the library, to provide all of the stubbing code to us, in exactly the way we need it.

The method below gives us the ability to stub the network with a 200 and a defined JSON response.

static func stubNetwork(forService service: Service.Type) {
     stub(condition: isHost(baseUrl)) { _ in
guard let path = OHPathForFileInBundle(stubPath(forService: service), Bundle.main) else {
preconditionFailure(“Stub not found!”)
}
      return OHHTTPStubsResponse(
fileAtPath: path,
statusCode: 200,
headers: [ “Content-Type”: “application/json” ]
)
}
}

I’ll explain what is happening here

  1. We pass in the type of service we want to stub. Service is a protocol, which all of our services conform to. This gives the ability to switch over the Service.Type and provide the stub data we need for each service.
  2. We give the stub a condition. This will be the host url that we want to stub. It will stub any request made with this url.
  3. We get the local file path which holds the JSON data for the response.
  4. Return this file, stubbing all requests made with this url until the stub is removed using OHHTTPStubs.removeAllStubs() .

Nimble

We chose to cut down on using some of the more verbose syntax and coding structure that is provided by XCTest, and went with using the Nimble framework.

This allowed us to go from setting up expectations with

let expect = expectation(description: “Get Receipts”)
...
expect.fulfill()
...
waitForExpectations(timeout: 2) { (error) in
XCTAssertTrue(asyncReceipts?.count > 0)
}

to simply:

expect(asyncReceipts!.count > 0).toEventually(beTrue())

👍🏻👍🏻👍🏻

Testing 🙂 path

This should be proven in a single test case. Just set up the success stub, call the service and assert the async result has been fulfilled.

var asyncReceipts: [Receipts]?
StubHelper.stubNetwork(forService: GetReceiptsService.self)
GetReceiptsService.getReceipts(forUser: UUID().uuidString) { (result) in
     switch result {
case .success(let receipts):
asyncReceipts = receipts
default: break
}
}
expect(asyncReceipts).toEventuallyNot(beNil())
expect(asyncReceipts!.count > 0).toEventually(beTrue())

Testing 🙁 paths

This is where the unit tests really start to prove their worth.

We can similarly write a test case for the network call, which will set up the stub for the error code we want to test, call the service, and assert that the errors are being set asynchronously.

var asyncError: Error?
stubNetworkWithNoConnection()
GetReceiptsService.getReceipts(forUser: “”) { (result) in
    switch result {
case .failure(let error):
asyncError = error
default: break
}
}
expect(asyncError).toEventuallyNot(beNil())
expect(asyncError as! HTTPError).toEventually(equal(HTTPError.noConnection))

That’s cool. But I hear you, this seems like it can be optimised. We don’t want to repeat this code every time we want to test for a different error.

Let’s Protocolise

So thinking about our requirements here, we need to test for every likely error response from the server, on each of our services. Also we need to assert that the errors coming back are what we expect.

ServiceTestable protocol

protocol ServiceTestable {
     func testFailures()
     func setupNetworkFailureTest(withStub stub: (() -> Void), andErrorToAssert errorAssertion: HTTPError)
}

Here I have defined a protocol, which will provide a harness for how we test our service classes. By ensuring our services conform to this protocol, we can call the testFailures() which will call out to the setupNetworkFailureTest() function, for each scenario we need. We then inject the stub and the error we want to assert into the test.

Now where is the implementation you ask? Hold up a second and I will tell you!

Enter protocol extensions

By extending our ServiceTestable protocol, we can give some default behaviour to our test cases.

extension ServiceTestable where Self: WeVatTests {
func testFailuresWithStatusCodes() {
let failures = [
         (stub: StubHelper.stubNetworkWithNotFound, error: HTTPError.notFound),
         (stub: StubHelper.stubNetworkWithForbidden, error: HTTPError.invalidCredentials),
         (stub: StubHelper.stubNetworkTimeout, error: HTTPError.timeout),
         (stub: StubHelper.stubNetworkNoConnection, error: HTTPError.noConnection),
         (stub: StubHelper.stubNetworkWithBadRequest, error: HTTPError.invalidRequest),
         (stub: StubHelper.stubNetworkWithServerError, error: HTTPError.serverError),
         (stub: StubHelper.stubNetworkWithUnauthorized, error: HTTPError.invalidCredentials),
         (stub: StubHelper.stubNetworkWithServerTimeout, error: HTTPError.timeout)
           ]
         for failure in failures {
              setupNetworkFailureTest(withStub: failure.stub, andErrorToAssert: failure.error)
         }
    }
}

This looks like a big old block o’ code, but what it will provide is pretty great. We set up an array of tuples, each array element holding

  1. A reference to the Stub we want to use.
  2. The error that we will assert against.

Now the classes that will conform to our ServiceTestable protocol will look something like this:

class GetReceiptsServiceTests: WeVatTests, ServiceTestable {
    override func tearDown() {
super.tearDown()
StubHelper.unStubNetwork()
}
    ...
    func testFailures() {
testFailuresWithStatusCodes()
}
    func setupNetworkFailureTest(withStub stub: (() -> Void), andErrorToAssert errorAssertion: HTTPError) {
        var asyncError: Error?
stub()
        GetReceiptsService.getReceipts(forUser: UUID().uuidString) { (result) in
              switch result {
case .failure(let error):
asyncError = error
default: break
}
}
         expect(asyncError).toEventuallyNot(beNil())
expect(asyncError as! HTTPError).toEventually(equal(errorAssertion))
}
}

I’ll walk you through what is now going on:

  1. We now ensure the service test classes are conforming to ServiceTestable protocol
  2. testFailures() is being called by XCTest when running our unit test suite
  3. We call out to testFailuresWithStatusCodes() in our protocol extension.
  4. The extension does the leg work in calling through to our implementation in setupNetworkFailureTest for each of the errors we want.

Wrap up

What we have now, is an extendable test harness for our service layer. When we add another service, it is easy to hook it up to the ServiceTestable protocol, which will ensure that all error cases are tested fully. If we want to add further error scenarios, we can just append them to the array and a reference to their corresponding error stub.

Hacker Noon is how hackers start their afternoons. We’re a part of the @AMIfamily. We are now accepting submissions and happy to discuss advertising & sponsorship opportunities.
To learn more, read our about page, like/message us on Facebook, or simply, tweet/DM @HackerNoon.
If you enjoyed this story, we recommend reading our latest tech stories and trending tech stories. Until next time, don’t take the realities of the world for granted!

More by Harry Bloom

Topics of interest

More Related Stories