Almost every app now has to deal with the Web. You're going to reach the internet and fetch data at some point. But how can we test such network code? Well, we have many options one can use, and each of them comes with its benefits and disadvantages. Let's see them in greater detail.

Approach 1: End-to-end tests

End-to-end tests mean that we are actually hitting the network: executing the HTTP request, going to the backend, getting the response back, and asserting the right response. Let's look at an example:

struct Task: Decodable {
  var name: String
  var isCompleted: Bool
}

extension String: Error {}

public struct APIClient {
  private var session: URLSession
  
  public init(session: URLSession = .shared) {
    self.session = session
  }
  
  public func execute<T: Decodable>(request: URLRequest) async throws -> T {
    let (data, response) = try await session.data(for: request)
    guard (response as? HTTPURLResponse)?.statusCode == 200 else {
      throw "Something wrong happens"
    }
    return try JSONDecoder().decode(T.self, from: data)
  }
}
import XCTest

class TaskNetworkTests: XCTestCase {
  
  func test_get_tasks() async {
    let client = APIClient()
    
    var request = URLRequest(url: URL(string: "https://your-backend-url.com/api/tasks")!)
    request.addValue("application/json;charset=utf-8", forHTTPHeaderField: "Accept")
    request.addValue("application/json", forHTTPHeaderField: "Content-Type")
    
    do {
      let tasks: [Task] = try await client.execute(request: request)
      XCTAssertEqual(tasks.count, 5)
    } catch {
      XCTFail("shouldn't get an error from the server")
    }
  }
  
}

Here we create a struct responsible for handling a network request, and in the test, we're actually hitting the network and asserting the response back. But we can detect a couple of problems in this approach:

  • What if we don't have a backend yet? Chances are, we want to avoid being blocked just by not having.
  • This kind of test can be flaky because they're hitting the network
  • End-to-end tests are not so useful at the component level in this context. It'll be more beneficial to have them test more components in integration. End-to-end tests are a valid solution, though, but we have more reliable solutions in our belt. Let's continue our exploration.

Approach 2: Subclass-based mocking

Let's see another option to test a network request. This time, we will subclass URLSession and URLSessionDataTask (stubbing them). To do so, we will create a spy URLSession and inject it into the APIClient constructor.

 class URLSessionHTTPClientTests: XCTestCase {
   
   func test_get_from_url_creates_data_task_with_url() async {
     let session = URLSessionSpy()
     let sut = APIClient(session: session) // 4
     let request = URLRequest(url: URL(string: "http://any-url.com")!)
     
     let tasks: [Task]? = try? await sut.execute(request: request)
     XCTAssertEqual(session.receivedRequests, [request])
   }
  
  private class URLSessionSpy: URLSession { // 1
    var receivedRequests = [URLRequest]() // 2
    
    override func data(for request: URLRequest, delegate: URLSessionTaskDelegate? = nil) async throws -> (Data, URLResponse) { // 3
      receivedRequests(request)
      return (Data([]), URLResponse()) 
    }
  }
  
}
  1. We create the URLSessionSpy, which inherits from URLSession.
  2. We declare a receivedRequests that we're going to assert and see if it contains the request
  3. We override the method that interest us that what will call on the client data (for:delegate:)
  4. In the test method, we create an APIClient and inject the fake URLSession (URLSessionSpy)
  5. Then, once we call the execute method on APIClient, we can assert that the receivedRequests is equal to the array of requests.

As you can see, it's really straightforward to mock the URLSession, but this method comes with numerous problems and breaks many principles:

  • We're subclassing URLSession, which is often dangerous because we don't own those classes, they are Foundation classes, and we don't have access to their implementation.
  • By subclassing classes we don't own, we can start making assumptions in our mocked behaviors that could be wrong.
  • And generally, the mocked class will contain several methods we aren't concerned about and don't know how those methods interact between them.
  • Our test is tied to the implementation (data(for:delegate)), which should be private. We should test just the behaviors.

That being said, now let's see a cleaner approach based on protocols.

Approach 3: Protocol-based mocking

Seeing all the drawbacks with subclass-based mocking, we can leverage the power of protocols to abstract our code. The common thing to do is click on the interested method, copy it, and put it in a protocol.

protocol HTTPSession {
  func data(for request: URLRequest, delegate: URLSessionTaskDelegate?) async throws -> (Data, URLResponse)
}

Here we already eliminate the problem of a class having a bunch of methods we need to override. We just put in that protocol what we need to care about. Once we do that, we want to change our APIClient struct to accept an instance of HTTPSession rather than the concrete URLSession.

extension String: Error {}

public struct APIClient {
  private var session: HTTPSession
  
  public init(session: HTTPSession) {
    self.session = session
  }
  
  public func execute<T: Decodable>(request: URLRequest) async throws -> T {
    let (data, response) = try await session.data(for: request, delegate: nil)
    guard (response as? HTTPURLResponse)?.statusCode == 200 else {
      throw "Something wrong happens"
    }
    return try JSONDecoder().decode(T.self, from: data)
  }
}

And our test will look like this 👇:

class URLSessionHTTPClientTests: XCTestCase {
   
   func test_get_from_url_creates_data_task_with_url() async throws {
     let session = URLSessionSpy()
     let url = URL(string: "http://any-url.com")!
     let sut = APIClient(session: session)
     let request = URLRequest(url: url)
     
     let _: [Task]? = try? await sut.execute(request: request)
     XCTAssertEqual(session.receivedRequests, [request])
   }
  
  private class URLSessionSpy: HTTPSession { 
    var receivedRequests = [URLRequest]()
    
    func data(for request: URLRequest, delegate: URLSessionTaskDelegate?) async throws -> (Data, URLResponse) {
      receivedRequests.append(request)
      return (Data([]), URLResponse())
    }
  }
  
}

Nothing has changed from the previous test, except URLSessionSpy does implement the HTTPSession protocol. This approach of testing your network code is much better than the previous one because we can limit the APIs we have access to, thus hiding all those details about URLSession we don't care about. Maybe the main downside has extra code in production with the extra protocol that matches the same interface of one of the URLSession APIs. That should be a smell because we introduce a new type for the sole purpose of testing even though it's a valid solution 😅, but it makes our code more complicated than it should be.

Approach 4: URLProtocol stubbing

Another option when testing HTTP clients, which will keep our production code clean and our tests more decoupled from the production code, is URLProtocol stubbing.

Instead of mocking, we will stub and intercept network requests and return stubbed values. There's a type in the foundation framework that will help us: the URLProtocol API. Let's see how it works: URLProtocol Stubbing Every time we perform a URL request, what happens behind the scene is there is a URL Loading System to handle the URL request, and as part of this URL Loading System, there is a type named URLProtocol, which is an abstract class that inherits from NSObject.

If we create our own URLProtocol subtype, we can start intercepting URL requests. For example, we have HTTP, HTTPS protocols, but we can make our own custom protocol. You'll potentially be asking why we would do that? There are a couple of cases where we want to create our own custom protocol; for instance, we can use it to implement a local cashing system, get some data for analytics purposes, or profile how long the request is; going to take, etc. All we have to do is implement some abstract methods of the class.

So, the point is, we can actually create a subclass of URLProtocol implementing our stub behaviors, so we can intercept the request during tests and finish it with stub request, so we never actually go to the internet, which will make our tests faster and reliable by eliminating the flakiness of network connections. We can hide those details from our production code as well. Furthermore, the test code will never know if we're using the URLSession or another mechanism for fetching URLs. We can use whatever we want (Alamofire, AFNetworking, URLSession, the legacy URLConnection, or anything that might come up in the future).

Enough talking, let's now see how to implement all that, and you might be surprised because it's quite simple.

struct Task: Decodable {
  var name: String
  var isCompleted: Bool
}

extension String: Error {}

public struct APIClient {
  private var session: URLSession
  
  public init(session: URLSession = .shared) {
    self.session = session
  }
  
  public func execute<T: Decodable>(request: URLRequest) async throws -> T {
    let (data, response) = try await session.data(for: request, delegate: nil)
    guard (response as? HTTPURLResponse)?.statusCode == 200 else {
      throw "Something wrong happens"
    }
    return try JSONDecoder().decode(T.self, from: data)
  }
}

Here we return to our primary implementation of the APIClient struct, as we had in the end-to-end approach.
Now let's subclass the URLProtocol abstract class and handle the network requests ourselves:

private final class URLProtocolStub: URLProtocol { // 1
  
  static var loadingHandler: ((URLRequest) throws -> (HTTPURLResponse, Data))? // 2
    
  override class func canInit(with request: URLRequest) -> Bool { // 3
    return true
  }
    
  override class func canonicalRequest(for request: URLRequest) -> URLRequest { // 4
    return request
  }
    
  override func startLoading() { // 5
    guard let handler = Self.loadingHandler else {
      XCTFail("Loading handler is not set.")
      return
    }
    do {
      let (response, data) = try handler(request)
      client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
      client?.urlProtocol(self, didLoad: data)
      client?.urlProtocolDidFinishLoading(self)
    } catch {
      client?.urlProtocol(self, didFailWithError: error)
    }
  }
    
  override func stopLoading() { } // 6

}
  1. We create a class that inherits from the URLProtocol class.
  2. We declare a closure that'll help us track the result of the request.
  3. We override canInit(with:) to indicate the system we're intercepting any request made.
  4. We override canonicalRequest(for:) and simply return the given request.
  5. The startLoading() is where the magic happens. We first verify the loadingHandler closure has been set; otherwise, we generate a failure immediately, then we pass the request to that closure. We take the result and pass it as a parameter to the system, either as a URLResponse with its associated Data or as an Error.
  6. We finally call the stopLoading() method ideal for testing request cancellations, but we need to override it even though we don't provide an implementation; otherwise, we'll see a crash.

Now the test will look like this:

class URLSessionHTTPClientTests: XCTestCase {
  
  func test_get_from_url_fails_on_request_error() async throws {
    let configuration = URLSessionConfiguration.ephemeral // 1
    configuration.protocolClasses = [URLProtocolStub.self] // 2
    let session = URLSession(configuration: configuration) 
    let sut = APIClient(session: session) 
    let request = URLRequest(url: URL(string: "http://any-url.com")!)
    
    URLProtocolStub.loadingHandler = { request in // 3
      XCTAssertEqual(request.url?.host, "any-url.com")
      throw NSError(domain: "any error", code: 1) 
    }
    
    do { // 4
      let _: [Task]? = try await sut.execute(request: request)
      XCTFail("shouldn't execute this block")
    } catch(let receivedError as NSError) { 
      XCTAssertEqual(receivedError.domain, error.domain)
      XCTAssertEqual(receivedError.code, error.code)
    }
  }
  
}
  1. We create a new URLSessionConfiguration and set it to ephemeral.
  2. We set the configuration protocolClasses property to an array of URLProtocolStub we created above.
  3. We call the URLProtocolStub.loadingHandler and throw an error since we are interested in the sad path.
  4. We call the sut execute method in a try...catch block verify we get the same error thrown in our assertions.

Conclusion

Here are four approaches to unit test your network code. Many of them come with their disadvantages, already explained above. Personally, I'll opt for the last solution: URLProtocol stubbing approach. It gives us a lot of flexibility and keeps our test decoupled from the production code, resulting in the latter being totally agnostic of the testing strategy. I recommend you watch these WWDC videos about testing: