Skip to content

Mockirinha - a approach to write unit test for API requests

Posted on:March 7, 2024 at 12:00 PM

The discussion around unit testing as a means to enhance software quality is commonplace today, with advocates of Test Driven Development (TDD) and various testing strategies. The ideal testing environment is one where there’s no need to create fake entities to test data retrieval methods from the API. However, in the mobile environment, this becomes challenging due to the need for abstraction in applications, allowing developers to create stable tests that work seamlessly in a Continuous Integration (CI) environment.

URLRequest mocks is complicated

This is the context in which the implementation at hand was created, addressing the challenges and aiming for a straightforward solution with a simple interface for quick understanding by fellow developers. Swift provides a way to manually capture and handle URLRequests within a URLSession through a protocol called URLProtocol. Initially, the goal was to implement a new communication protocol and emulate it within URLSession and URLRequest. This approach enables applications already utilizing this API to seamlessly transition to a new communication protocol without requiring major alterations.

import Foundation

class MockURLProtocol: URLProtocol {
    static var requestHandler: ((URLRequest) throws -> (HTTPURLResponse, Data))?

    override class func canInit(with request: URLRequest) -> Bool {
        // Customize the conditions under which this protocol should handle the request.
        return true
    }

    override class func canonicalRequest(for request: URLRequest) -> URLRequest {
        // If necessary, adjust the request or return a new one.
        return request
    }

    override func startLoading() {
        guard let handler = MockURLProtocol.requestHandler else {
            fatalError("Handler is unavailable.")
        }

        do {
            // Call the custom request handler and get the response and data.
            let (response, data) = try handler(request)

            // Send the response to the client.
            client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)

            // Send the data to the client.
            client?.urlProtocol(self, didLoad: data)

            // Notify that loading is complete.
            client?.urlProtocolDidFinishLoading(self)
        } catch {
            // Notify that an error occurred.
            client?.urlProtocol(self, didFailWithError: error)
        }
    }

    override func stopLoading() {
        // Implement any cleanup code if needed.
    }
}

This example sets up a MockURLProtocol` that allows you to define a custom request handler. The handler determines the response and data to be returned for a given request. This protocol can be useful for testing API interactions without actually making network requests. Adjust it as needed for your specific testing requirements.

Depedencies, dependencies and dependencies

Another fundamental concept is that of dependency injection, where a method or object receives its dependencies as parameters during its execution. This helps keep that function or object as pure as possible and facilitates the task of writing unit tests for it. In our example here, the dependency we are interested in is URLSession, which is often used with its shared instance by default. However, in this case, it will be a parameter of what we want to test. This allows us to create an instance with a URLProtocol that enables us to specify the desired results.

import Foundation

class APIClient {
    // Dependency: URLSession
    private let urlSession: URLSession

    // Initialize with dependency injection
    init(urlSession: URLSession = URLSession.shared) {
        self.urlSession = urlSession
    }

    // Method making API call with injected URLSession
    func fetchData(completion: @escaping (Result<Data, Error>) -> Void) {
        guard let url = URL(string: "https://sample.com/api/data") else {
            completion(.failure(NSError(domain: "Invalid URL", code: 0, userInfo: nil)))
            return
        }

        var request = URLRequest(url: url)
        request.httpMethod = "GET"

        // Use the injected URLSession for the API request
        let task = urlSession.dataTask(with: request) { (data, response, error) in
            if let error = error {
                completion(.failure(error))
                return
            }

            if let data = data {
                completion(.success(data))
            } else {
                completion(.failure(NSError(domain: "No data received", code: 1, userInfo: nil)))
            }
        }

        task.resume()
    }
}

// Example usage
let apiClient = APIClient()

apiClient.fetchData { result in
switch result {
case .success(let data):
    print("Data received: \(data)")
case .failure(let error):
    print("Error: \(error)")
}
}

Mockirinha

The Mockirinha comes into play as an implementation of URLProtocol with a simple DSL (Domain-Specific Language) for generating unit tests in layers that use URLSession to make API calls. The concept is to create a block of code that captures all requests and mocks them, allowing you to verify the behavior of these methods. It provides a straightforward implementation, making it accessible for any Swift developer to understand the code and potentially create their version for more specific cases.

Unit tests and context

The primary challenge addressed by Mockirinha is establishing the concept of context and ensuring that one test does not impact another, especially in projects where tests might run concurrently. Since URLProtocol does not get instantiated, and its variables need to be global, the strategy involves using a key for a different context than the URL. Instead, it utilizes data from where the context was created, and after completion, it is removed from the global data.

Another aspect is enabling inferences about behavior, especially in more complex scenarios with multiple calls. It allows verification if these calls occurred as expected. Another crucial piece of information in such tests is checking for additional API calls that were not planned. This is a common behavior in reactive applications but can consume resources like battery life, which is critical in the mobile environment. Hence, at the end of the context execution, you can examine and verify these pieces of information.

Sample

This test ensures that the Mockirinha library can successfully stub a response for a specific URL, execute an asynchronous API call, and provide a report with accurate information about the executed requests.

func testSuccessfulWithReport() async throws {
    let report = await stub(response: .unique(.url(URL(string: "https://google.com")!), .empty(.create))) { session in
        do {
            let (_, response) = try await session.data(from: URL(string: "https://google.com")!)
            if let httpResponse = response as?  HTTPURLResponse {
                XCTAssertEqual(201,  httpResponse.statusCode)
            }
        } catch {
            XCTFail("Fail the request")
        }
    }
    XCTAssertEqual(report.requests.count, 1)
    XCTAssertEqual(report.requests[0].method, "GET")
    XCTAssertEqual(report.executedMock.count, 1)
    XCTAssertEqual(report.totalExecuted, 1)
}

Conclusion

The goal of this post is to provide insights into the implementation of a library like Mockirinha. The code is well-documented, making it a valuable resource for study purposes. It is my hope that this post aids in understanding the strategy behind such libraries, empowering developers to conduct more effective unit testing in mobile development and reap the benefits of coding with increased confidence.

The project is hosted on the GitHub repository: Mockirinha Repository