Mocking URLProtocol and Unit Testing the Network Stack on iOS using Swift

In the previous post, we learned how to write a reliable and scalable network stack on the iOS platform. However, that is just the beginning. After writing the implementation, we also need to unit test it to make sure it's stable with all the business logic and unintended changes do not go unnoticed.

If you're interested in reading a previous post, here you go

Building a Reliable and Testable Networking Stack on iOS
Build a networking stack to do network operations on iOS and Swift. Build network layer in the iOS app. Networking on iOS and in Swift with code

We will write unit tests for this network stack in today's blog post. Before writing actual tests, let's work on a few building components necessary for testing and then we will bind them all together.

1.Building a JSONDataReader Utility

To test the network service, first, we need a sample JSON response. We will store it in the local JSON file and use the JSONDataReader to read and convert it into the Data object that will be returned by the URLProtocol mock (Explained below)


final class JSONDataReader {
    static func getDataFromJSONFile(with name: String) -> Data? {
        guard let pathString = Bundle(for: self).path(forResource: name, ofType: "json") else {
            XCTFail("Mock JSON file \(name).json not found")
            return nil
        }

        guard let jsonString = try? String(contentsOfFile: pathString, encoding: .utf8) else {
            return nil
        }

        guard let jsonData = jsonString.data(using: .utf8) else {
            return nil
        }
        return jsonData
    }
}

2. Mocking URLProtocol for mocking responses

In the next part, we are going to mock URLProtocol. By mocking it, we can control what data, response, and error is being sent by the API when it tries to make a network call. The purpose here is not to check whether it can decode the response or not. That is handled in other tests. Here we want to test whether our network service can handle the arbitrary responses, data, and errors we throw at it after the network request is finished.


// Referenced from: https://forums.raywenderlich.com/t/chapter-8-init-deprecated-in-ios-13/102050/6
class URLProtocolMock: URLProtocol {
    static var mockURLs = [URL?: (error: Error?, data: Data?, response: HTTPURLResponse?)]()

    override class func canInit(with request: URLRequest) -> Bool {
        return true
    }

    override class func canonicalRequest(for request: URLRequest) -> URLRequest {
        return request
    }

    override func startLoading() {
        if let url = request.url {
            if let (error, data, response) = URLProtocolMock.mockURLs[url] {

                if let responseStrong = response {
                    self.client?.urlProtocol(self, didReceive: responseStrong, cacheStoragePolicy: .notAllowed)
                }

                if let dataStrong = data {
                    self.client?.urlProtocol(self, didLoad: dataStrong)
                }

                if let errorStrong = error {
                    self.client?.urlProtocol(self, didFailWithError: errorStrong)
                }
            }
        }

        DispatchQueue.main.async {
            self.client?.urlProtocolDidFinishLoading(self)
        }
    }

    override func stopLoading() {
        // no-op
    }
}

The URLProtocolMock inherits from URLProtocol and overrides its method which is called while getting data from the network.

We also map URLs to (error, data, response) tuple so that once it finds the matching tuple, it returns it as a network response and we test whether our network stack correctly reacts to the kind of response it receives.

3. Setting up URL protocol mocks

Before we even start writing our tests, we need to make sure we set up our protocol mock so that we can use the mocked version instead of the real URL protocol.

We will call it in the setUp method to make sure everything is set up before running our first test and we're using the mocked version of URLProtocol


....
...

override func setUp() {
    super.setUp()
    setupURLProtocolMock()
    .....
    ...
}

private func setupURLProtocolMock() {
    let sessionConfiguration = URLSessionConfiguration.ephemeral
    sessionConfiguration.protocolClasses = [URLProtocolMock.self]
    mockSession = URLSession(configuration: sessionConfiguration)
}

4. Writing tests for RequestHandler

Writing tests for this component will involve three scenarios

a. Testing for a Successful Response

To test the successful response, we will create a fake URL and map it to the collection of nil error, valid server data, and successful response. We will use the mockURLs property on URLProtocolMock to set it up. As soon as the URL session starts loading data, it maps this URL to a tuple containing these values and is returned as a response.


func testThatNetworkServiceCorrectlyHandlesValidResponse() {
    let urlWithValidData = "https://valid_data.url"
    let validData = JSONDataReader.getDataFromJSONFile(with: "species")

    URLProtocolMock.mockURLs = [
        URL(string: urlWithValidData): (nil, validData, validResponse)
    ]
    ....
    ...
}

Since the network service returned data in an asynchronous manner, we are going to use Swift expectations.

Expectations are generally used in the context of async tests.

Once we map the URL to the expected response, we request data through network service by calling request method. Since there is no error and it's a valid response, we expect the result case to be success. If it's a success, we do nothing. If it hits the failure case, we fail the test.

Here is the full code,


final class RequestHandlerTests: XCTestCase {

    var mockSession: URLSession!
    let validResponse = HTTPURLResponse(url: URL(string: "https://something.com")!, statusCode: 200, httpVersion: nil, headerFields: nil)
    let invalidResponse = HTTPURLResponse(url: URL(string: "https://something.com")!, statusCode: 400, httpVersion: nil, headerFields: nil)
    var networkService: RequestHandler!

    override func setUp() {
        super.setUp()
        setupURLProtocolMock()
            networkService = RequestHandler(urlSession: mockSession)
    }

    override func tearDown() {
        super.tearDown()
        URLProtocolMock.mockURLs = [:]
    }

    func testThatNetworkServiceCorrectlyHandlesValidResponse() {
        let urlWithValidData = "https://valid_data.url"
        let validData = JSONDataReader.getDataFromJSONFile(with: "species")

        URLProtocolMock.mockURLs = [
            URL(string: urlWithValidData): (nil, validData, validResponse)
        ]

        let expectation = XCTestExpectation(description: "Successful JSON to model conversion while loading valid data from API")

        networkService.request(type: SpeciesResponse.self, route: .getSpecies(URL(string: urlWithValidData)!)) { result in
            if case .success = result {
                // No-op. If we reached here, that means we passed the test
            } else {
                XCTFail("Test failed. Expected to get the valid data without any error. Failed due to unexpected result")
            }
            expectation.fulfill()
        }

        wait(for: [expectation], timeout: 2.0)
    }
    

b. Testing for generic error

Our network service is expected to return the result with failure and the error case if it encounters an error in response. We will test this behavior by mapping a sample failing URL to a generic error case, empty data, and valid response.


let urlWithError = "https://random_error"

URLProtocolMock.mockURLs = [
    URL(string: urlWithError): (DataLoadError.genericError("Something went wrong"), nil, validResponse)
]

Once the URL session data task returns, we expect it to contain the error and return the result with the failure case and an error message string.


func testThatNetworkServiceCorrectlyHandlesResponseWithError() {
    let urlWithError = "https://random_error"

    URLProtocolMock.mockURLs = [
        URL(string: urlWithError): (DataLoadError.genericError("Something went wrong"), nil, validResponse)
    ]

    let expectation = XCTestExpectation(description: "Unsuccessful data load operation due to generic error data")

    networkService.request(type: SpeciesResponse.self, route: .getSpecies(URL(string: urlWithError)!)) { result in
        if case .failure = result {
            // No-op. If we reached here, that means we passed the test
        } else {
            XCTFail("Test failed. Expected to get the DataLoadError with type genericError with error message. Failed due to unexpected result")
        }
        expectation.fulfill()
    }
    wait(for: [expectation], timeout: 2.0)
}

c. Testing for invalid response code

We also want to make sure we show the error message to users when the app receives an invalid response code. That is - a response code other than 200.

We will select an URL and map it to a tuple containing an empty error, invalid data, and an invalid response object.


let urlWithInvalidResponse = "https://invalid_response"
let invalidResponse = HTTPURLResponse(url: URL(string: "https://github.com")!, statusCode: 400, httpVersion: nil, headerFields: nil)
let invalidData = JSONDataReader.getDataFromJSONFile(with: "species_invalid")

URLProtocolMock.mockURLs = [
    URL(string: urlWithInvalidResponse): (nil, invalidData, invalidResponse)
]

let expectation = XCTestExpectation(description: "Unsuccessful data load operation due to invalid response code")


Once the data task returns the response, our web service should capture the invalid response and return the failure case wrapping invalidResponseCode type and response code as an associated value.


URLProtocolMock.mockURLs = [
    URL(string: urlWithInvalidResponse): (nil, invalidData, invalidResponse)
]

let expectation = XCTestExpectation(description: "Unsuccessful data load operation due to invalid response code")

networkService.request(type: SpeciesResponse.self, route: .getSpecies(URL(string: urlWithInvalidResponse)!)) { result in
    if case .failure(.invalidResponseCode(let code)) = result {
        XCTAssertEqual(code, 400)
    } else {
        XCTFail("Test failed. Expected to get the DataLoadError with type invalidResponseCode with error message. Failed due to unexpected result")
    }
    expectation.fulfill()
}
wait(for: [expectation], timeout: 2.0)

Summary

So that's all about testing our network stack covering success as well as all its edge cases by mocking URLProtocol object.

I had two goals in mind for this blog post. First, how to write tests with comprehensive test coverage for the network stack. The thing we usually miss, but is of utmost importance as the network stack is used throughout the app and any failure can affect the entire app and not just an isolated portion.

Second, I also wanted to show how to mock the URLProtocol object. Usually, it is not done often, but for this case, we need to provide a mock to our network service so that we can better control what kind of data it sends in the completion handler and better prepare for it.

Source Code

The complete source code for this tutorial is available on GitHub Gist for further reference

Support and Feedback

If you have any comments or questions, please feel free to reach out to me on LinkedIn.

If you like my blog content and wish to keep me going, please consider donating on Buy Me a Coffee or Patreon. Help, in any form or amount, is highly appreciated and it's a big motivation to keep me writing more articles like this.

Consulting Services

I also provide a few consulting services on Topmate.io, and you can reach out to me there too. These services include,

  1. Let's Connect
  2. Resume Review
  3. 1:1 Mentorship
  4. Interview Preparation & Tips
  5. Conference Speaking
  6. Take-home Exercise Help (iOS)
  7. Career Guidance
  8. Mock Interview