Testing network calls using URLProtocol

Testing network calls is a critical aspect of ensuring the reliability and stability of your iOS applications. We'll explore how to effectively test network calls in Swift using the URLProtocol API. With this guide, you will have powerful tools to ensure code quality.

Setup

We will use a very simple network layer implementation, using dogs API:

struct DogsList: Decodable {
    
    enum CodingKeys: String, CodingKey {
        case dogImages = "message"
    }
    let dogImages: [URL]
}

final class DogApi {
    let endpoint = URL(string: "https://dog.ceo/api")!
    
    let session: URLSession
    let decoder: JSONDecoder
    
    init(
        session: URLSession,
        decoder: JSONDecoder
    ) {
        self.session = session
        self.decoder = decoder
    }
    
    func getDogImages() async throws -> DogsList {
        return try await request(url: endpoint.appendingPathComponent("breed/hound/images"))
    }
    
    private func request<T>(url: URL) async throws -> T where T: Decodable {
        let (data, _) = try await session.data(from: url)
        return try decoder.decode(T.self, from: data)
    }
}

Our DogApi class make request with a given URL, waits for data, and maps it to a simple model.

The problem

How we would like to test this functionality? We shouldn't make real network code to real API when executing tests.

We could make a protocol. Extract the method signature from the URLSession, and make it conform to the new interface. Use new type instead of plain URLSession. It would work, but every time we need to use a new method from the session class, we have to modify the protocol and modify every mock in test cases. This is far from the ideal solution.

URLProtocol

URLProtocol is an abstract class that when overridden, allows us to intercept URLRequest that that are made by code. This way, we don't have to mock whole session, we can make it use our mocked URLProtocol.

Implementation

We will start with implementing our mock URL protocol:

class MockURLProtocol: URLProtocol {
        static var requestHandler: ((URLRequest) throws -> (HTTPURLResponse, Data))?
        
        override class func canInit(with request: URLRequest) -> Bool {
            return true
        }
        
        override class func canonicalRequest(for request: URLRequest) -> URLRequest {
            return request
        }
        
        override func startLoading() {
            guard let handler = MockURLProtocol.requestHandler else {
                XCTFail("No request handler provided.")
                return
            }
            
            do {
                let (response, data) = try handler(request)
                
                client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
                client?.urlProtocol(self, didLoad: data)
                client?.urlProtocolDidFinishLoading(self)
            } catch {
                XCTFail("Error handling the request: \(error)")
            }
        }
        
        override func stopLoading() {}
    }

Our subclass implements three important methods:

class func canInit(with request: URLRequest) -> Bool - this tells the session if the given request should be handled using this protocol. In our case we want all requests to be handled by mocked protocol.

class func canonicalRequest(for request: URLRequest) -> URLRequest - this method should guarantee that the same input request always yields the same canonical form. We will return an unmodified request. In a real implementation, you should put an effort to make sure the contract is honored.

func startLoading() - here goes our mocking logic. We check if requestHandler is set, gets data, or rethrows error. Lastly informs its client about finished loading.

Testing

Now we are ready to use it in the test:

class DogApiTests: XCTestCase {
    
    var api: DogApi!
    var session: URLSession!
    
    override func setUp() {
        super.setUp()
        
        // Set up URLSession with MockURLProtocol
        session = {
            let configuration = URLSessionConfiguration.ephemeral
            configuration.protocolClasses = [MockURLProtocol.self]
            return URLSession(configuration: configuration)
        }()
        
        // Initialize DogApi with the mocked session
        api = DogApi(session: session, decoder: JSONDecoder())
    }
    
    override func tearDown() {
        api = nil
        session = nil
        MockURLProtocol.requestHandler = nil
        super.tearDown()
    }
    
    func testGetDogImages() async throws{
        // Arrange
        let mockData = """
        {
            "message": ["https://example.com/dog1.jpg", "https://example.com/dog2.jpg"],
            "status": "success"
        }
        """.data(using: .utf8)!
        
        MockURLProtocol.requestHandler = { request in
            // Assert that the request is made to the correct endpoint
            XCTAssertEqual(request.url?.absoluteString, "https://dog.ceo/api/breed/hound/images")
            
            // Return a mocked response
            let response = HTTPURLResponse(
                url: request.url!,
                statusCode: 200,
                httpVersion: nil,
                headerFields: nil
            )!
            return (response, mockData)
        }
        
        // Act
        let result = try await api.getDogImages()
        
        // Assert
        XCTAssertEqual(result.dogImages.count, 2)
        XCTAssertEqual(result.dogImages[0], URL(string: "https://example.com/dog1.jpg"))
        XCTAssertEqual(result.dogImages[1], URL(string: "https://example.com/dog2.jpg"))
    }
}

We follow the Arrange/Act/Assert steps in the test.

In the arrange phase we are preparing a mocked payload to be returned, successful response with 2xx code.

The act step is executing a request from our API class.

Assert checks if data is decoded properly.

Conclusions

By using a custom MockURLProtocol, we can effectively test network calls in Swift. This approach allows us to control the behavior of network requests during testing, ensuring that our networking code behaves as expected under various conditions.

Remember to extend your test suite to cover edge cases, such as error scenarios and different HTTP status codes, to create robust and reliable tests for your network layer.