0

I want to write unit tests for my DataService. I read that you don't actually want to make network requests in the tests, and instead, you should make a MockDataService. However, what should the body of the fetchBusinesses method look like in the MockDataService? I need to account for cases of invalidURL, fail to decode etc...

protocol DataServiceProtocol {
    func fetchBusinesses(location: CLLocationCoordinate2D) async throws -> [Business]
}

final class DataService: DataServiceProtocol {
    func fetchBusinesses(location: CLLocationCoordinate2D) async throws -> [Business] {
    
        let url = try createURL(latitude: location.latitude, longitude: location.longitude)
        let request = setupURLRequest(url: url)
        
        let (data, response) = try await URLSession.shared.data(for: request)
        
        guard let response = response as? HTTPURLResponse, response.statusCode == 200 else {
            let statusCode = (response as! HTTPURLResponse).statusCode
            throw DataServiceError.invalidStatusCode(statusCode: statusCode)
        }
        
        let decoder = JSONDecoder()
        decoder.keyDecodingStrategy = .convertFromSnakeCase
        
        let decodedAPIResponse = try decoder.decode(SearchResponse.self, from: data)
        return decodedAPIResponse.businesses
    }
    
    private func createURL(latitude: Double, longitude: Double) throws -> URL {
        let endpoint = "https://api.yelp.com/v3/businesses/search?latitude=\(latitude)&longitude=\(longitude)&sort_by=distance&term=boba"
        guard let url = URL(string: endpoint) else {
            throw DataServiceError.invalidURL
        }
        return url
    }
    
    private func setupURLRequest(url: URL) -> URLRequest {
        let apiKey = "<API Key>"
        
        var request = URLRequest(url: url)
        request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
        return request
    }
}
aDabOfRanch
  • 137
  • 9
  • Do you know about subclassing URLProtocol? – matt Jul 22 '23 at 02:18
  • @matt No I do not. Is that something I should create a mock of instead of my DataService? – aDabOfRanch Jul 22 '23 at 02:47
  • 1
    You do not mock the DataService to test the DataService. You mock everything it talks to including the network. That is what URLProtocol is for. To test things that call the DataService, then you mock the DataService. You test only your own code and only the subject under test. – matt Jul 22 '23 at 03:05
  • Watch https://developer.apple.com/videos/play/wwdc2018/417/ – matt Jul 22 '23 at 03:47

1 Answers1

0

Taking Matt's advice I created a MockURLProtocol that subclasses the abstract class URLProtocol. A resource that helped with this was Medium Article: How to Mock URLSession Using URLProtocol

final class MockURLProtocol: URLProtocol {
    
    // 1. Handler to test the request and return mock response.
    static var loadingHandler: ((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.loadingHandler else {
            fatalError("Handler not set.")
        }
        
        do {
            // 2. Call handler with received request and capture the tuple of response and data.
            let (response, data) = try handler(request)
            
            // 3. Send received response to the client.
            client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
            
            if let data = data {
              // 4. Send received data to the client.
              client?.urlProtocol(self, didLoad: data)
            }
            
            // 5. Notify request has been finished.
            client?.urlProtocolDidFinishLoading(self)
        } catch {
            // 6. Notify received error.
            client?.urlProtocol(self, didFailWithError: error)
        }
    }
    
    override func stopLoading() {}
}

Here was a sample test case I was able to create.

final class DataService_Tests: XCTestCase {
    
    private var dataService: DataService!
    private var expectation: XCTestExpectation!
    
    override func setUp() {
        let configuration = URLSessionConfiguration.default
        configuration.protocolClasses = [MockURLProtocol.self]
        let urlSession = URLSession.init(configuration: configuration)
        
        dataService = DataService(urlSession: urlSession)
        expectation = expectation(description: "Expectation")
    }
    
    func test_fetchBusinesses_successfulResponse() async {
        let jsonString = """
                         {
                            "businesses": []
                         }
                         """
        let data = jsonString.data(using: .utf8)

        MockURLProtocol.loadingHandler = { request in
            let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!
            return (response, data, nil)
        }

        do {
            let returnedBusinesses = try await dataService.fetchBusinesses(location: CLLocationCoordinate2D(latitude: 1, longitude: 1))
            XCTAssertTrue(returnedBusinesses.isEmpty)
            self.expectation.fulfill()
        } catch {
            XCTFail("Unexpected error: \(error)")
        }

        wait(for: [expectation], timeout: 1.0)
    }
}
aDabOfRanch
  • 137
  • 9