Mocking API calls using URLProtocol

You,Swift

If we want to deliver high quality code, I believe supporting our project with a good amount of unit testing is so important, in fact it's a baseline tool for any growing team building a sustainable engineering practices. So, how do we test our networking layer? this can be tricky if we rely on real API, because our API might not be ready or we might mess with our production data. In order to avoid this, we can actually mock our API by intercepting the response without accessing a real server. Also by doing this, we can improve our development process. As long as we know what our API might look like and what we expect, we don’t have to rely on a real API to test our networking layer. We can continue with development and test our code without wasting time. Let's dive in to see how we can implement this:

Implementation

Before demonstrating the test cases, first lets setup a networking service class with URLSession to initiate networking request. This class has a function to implement data task with URLRequest, and transform the response into expected data type using Codable protocol. To make this function reusable and handle different kind of expected data type we use generic.

we could implement this class as follows:

class NetworkingService {
    private let session: URLSession
    
    init(session: URLSession) {
        self.session = session
    }
    
    func someFunctionToMockAPICall<T: Decodable>(_ request: URLRequest, requestDataType: T.Type, completionHandler: @escaping (Result<T, NewrokingError>) -> Void) {

        session.dataTask(with: request) { (data, response, error) in
            if let error = error {
                completionHandler(.failure(.connection(error)))
                return
            }
            
            guard let urlResponse = response as? HTTPURLResponse else {
                completionHandler(.failure(.unknown))
                return
            }
            
            switch urlResponse.statusCode {
            case 200..<300:
                do {
                    let payload = try JSONDecoder().decode(requestDataType, from: data ?? Data())
                    completionHandler(.success(payload))
                } catch let error {
                    completionHandler(.failure(.unableToDecode(error)))
                }
            default:
                completionHandler(.failure(.response(urlResponse.statusCode)))
            }
        }.resume()
    }
}

enum NewrokingError: Error {
    case response(Int)
    case unableToDecode(Error)
    case connection(Error)
    case unknown
}

enum HTTPStatus: Int {
    case notFound = 404
    case success = 200
    //some more status code
}

Custom URLProtocol and URLSession

Like I mentioned earlier to mock our API we can simply intercept the API response and in order to achieve this, we take advantage of URLProtocol and URLSession. We will create a mock class that inherits from URLProtocol to handle our requests, and we will pass that mock class to our URLSession. When we inherit from URLProtocol we need to override 4 functions as mentioned in this WWDC18 presentation https://developer.apple.com/videos/play/wwdc2018/417/ (opens in a new tab). These functions are:

Initially this will look like this:

class MockURLProtocol: URLProtocol {
    static var requestHandler: ((URLRequest) throws -> (HTTPURLResponse, Data?))?
    
    override class func canInit(with request: URLRequest) -> Bool {
        // This function checks if the protocol can handle the given request.
        return true
    }
    
    override class func canonicalRequest(for request: URLRequest) -> URLRequest {
        // This returns a canonical version of the given request, we simply return the same request.
        return request
    }
    
    override func startLoading() {
        // In this function we intercept the response as per our test case and pass it to URLProtocol client.

    }
    
    override func stopLoading() {
        // Since this protocol is not async, we leave it empty.
    }
}

Now we can add our logic in the startLoading function, we can configure a custom URLSession using URLSessionConfiguration. We will also add a static variable, let's call it requestHandler to validate a request and create a mock response.

we could write our logic in startLoading functions as follows:

    guard let handler = MockURLProtocol.requestHandler else { return }

    do {
        let (response, data)  = try handler(request)
        if let data = data {
            client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
            client?.urlProtocol(self, didLoad: data)
            client?.urlProtocolDidFinishLoading(self)
        }
    } catch  {
        client?.urlProtocol(self, didFailWithError: error)
    }

Now we are done with this class, let's move on to the actual test class and integrate what we have created so far.

Integration

Couple of more things we need to do before we can run our test case. First, we need to configure a custom URLSession, we can do this with the help of URLSessionConfiguration and pass the session to our service class to initiate the request. Second, we need to set the requestHandler we added earlier in the mock URLProtocol class to return data. Let me show you in code what I mean.

class NetworkingServiceTests: XCTestCase {

    override func tearDown() {
        MockURLProtocol.requestHandler = nil
    }
    
    func testSuccessAPIResponse() {
        let request = URLRequest(url: URL(string: "https://www.example.com")!)
        let employee = Employee(name: "Dwayne Johnson")
//        let anotherEmployee = Employee(name: "Ezaden")
        let jsonData = try! JSONEncoder().encode(employee)
        
        MockURLProtocol.requestHandler = { request in
            let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: "2.0", headerFields: nil)!
            return (response, jsonData)
        }
        
        let expectation = XCTestExpectation(description: "HTTP response with a 200 response code")
        let configuration = URLSessionConfiguration.ephemeral
        configuration.protocolClasses = [MockURLProtocol.self]
        let session = URLSession(configuration: configuration)
        
        let service = NetworkingService(session: session)
        service.someFunctionToMockAPICall(request, requestDataType: Employee.self) { result in
            switch result {
            case .success(let data):
//                XCTAssertEqual(data, anotherEmployee) //this will fail
                XCTAssertEqual(data, employee)
            case .failure(let error):
                XCTFail("Request was not successful: \(error.localizedDescription)")
            }
            
            expectation.fulfill()
        }
        
        self.wait(for: [expectation], timeout: 1)
    }

}

struct Employee: Codable, Equatable {
    let name: String
}

As you can see in the above code snippet, we created a test class, and added a function to test for success API response. We set a request handler with a desired URLResponse and data, we can also change our response status code to 404 if we want to test for a failure.

MockURLProtocol.requestHandler = { request in
    let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: "2.0", headerFields: nil)!
    return (response, jsonData)
}

we also configured a custom session with the help of URLSessionConfiguration and specified as ephemeral, which is similar to default session configuration except it doesn’t keep any caches or cookies. and we passed the session to our service class to initiate request.

 let configuration = URLSessionConfiguration.ephemeral
 configuration.protocolClasses = [MockURLProtocol.self]
 let session = URLSession(configuration: configuration)

Conclusion

Testing our networking layer can sound a bit tricky initially but its a simple as intercepting the API response with a custom URLProtocol and URLSession. If you want to learn more, there is a good presentation about Testing Tips & Tricks from WWDC 18, check it out. Thank you for reading, I hope you liked it and any feedback is much appreciated.

© Ezaden Seraj.