read

Unit testing for networking API requires some work because to mock the HTTP response is non-trivial, be it using 1st party URLSession or 3rd party Alamofire.

To do that easily, you can use libraries such as the popular OHHTTPStubs or Mocker by WeTransfer.

But you could also as easily make use of URLProtocol, which is part of Foundation framework.

What is URLProtocol?

First off, URLProtocol is actually a class, not a protocol. This is because it was invented way before there is Swift, since iOS 2. It is the system framework for managing who can handle a networking request.

We can make use of it to hijack all API requests, and return a mock.

Implementing a mock

This following subclass will be able to hijack all requests. There are 5 methods to override. But the gist is in startLoading(), where we look at request.url, find our mock data, then return it with client?.urlProtocol(self, didLoad: data).

class MockURLProtocol: URLProtocol {

    // A dictionary of mock data, where keys are URL path eg. "/weather?country=SG"
    static var mockData = [String: Data]()

    override class func canInit(with task: URLSessionTask) -> Bool {
        return true
    }

    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 {
            let path: String
            if let queryString = url.query {
                path = url.relativePath + "?" + queryString
            } else {
                path = url.relativePath
            }
            let data = MockURLProtocol.mockData[path]!
            client?.urlProtocol(self, didLoad: data)
            client?.urlProtocol(self, didReceive: HTTPURLResponse(), cacheStoragePolicy: .allowed)
        }
        client?.urlProtocolDidFinishLoading(self)
    }

    override func stopLoading() {}

}

Possible Pitfall: For Moya, they process and require a non-nil response, else they throw an “underlying error”. Hence there is a call to urlProtocol(didReceive:cacheStoragePolicy:) with an empty HTTPURLResponse.

How to use the mock

To use, there are 2 important steps.

// Step 1: Register our class can handle
URLProtocol.registerClass(MockURLProtocol.self)
// Step 2: Use a custom URLSessionConfiguration
let configurationWithMock = URLSessionConfiguration.default
configurationWithMock.protocolClasses?.insert(MockURLProtocol.self, at: 0)

// To use for Alamofire
SessionManager(configuration: configurationWithMock)

// To use for URLSession
URLSession(configuration: configurationWithMock)

Lastly, in case it is not obvious, you have to supply the mock data. Eg.

MockURLProtocol.mockData["/weather?country=SG"] = ...

Unit testing

With the mock, you can write test cases for the API fetch methods, and it will run fast since mock data will be returned immediately.

In your setUp() method, include the steps to use the mock, and supply the mock data.

Then write test cases as per normal.

func testFetch() throws {
    let expectation = expectation(description: "Fetching")

    // Test the API method...

    waitForExpectations(timeout: 5)
}

NOTE: You still must have waitForExpectations(timeout:), because the API methods are still asynchronous. Without that, startLoading might not even have the chance to be called.


Image

@samwize

¯\_(ツ)_/¯

Back to Home