Mocking a network connection in your Swift Tests

Published on: December 12, 2024

Unit tests should be as free of external dependencies as possible. This means that you want to have full control over everything that happens in your tests.

For example, if you're working with a database, you want the database to be empty or in some predefined state before your test starts. You operate on the database during your test and after your test the database can be thrown away.

By making your tests not depend on external state, you make sure that your tests are repeatable, can run in parallel and don't depend on one test running before another test.

Historically, something like the network is particularly hard to use in tests because what if your test runs but you don't have a network connection, or what if your test runs during a time where the server that you're talking to has an outage? Your tests would now fail even though there's nothing wrong with your code. So you want to decouple your tests from the network so that your tests become repeatable, independent and run without relying on some external server.

In this post, I'm going to explore two different options with you.

One option is to simply mock out the networking layer entirely. The other option uses something called URLProtocol which allows us to take full control over the requests and responses inside of URLSession, which means we can actually make our tests work without a network connection and without removing URLSession from our tests.

Defining the code that we want to test

In order to properly figure out how we're going to test our code, we should probably define the objects that we would like to test. In this case, I would like to test a pretty simple view model and networking pair.

So let's take a look at the view model first. Here's the code that I would like to test for my view model.

@Observable
class FeedViewModel {
  var feedState: FeedState = .notLoaded
  private let network: NetworkClient

  init(network: NetworkClient) {
    self.network = network
  }

  func fetchPosts() async {
    feedState = .loading
    do {
      let posts = try await network.fetchPosts()
      feedState = .loaded(posts)
    } catch {
      feedState = .error(error)
    }
  }

  func createPost(withContents contents: String) async throws -> Post {
    return try await network.createPost(withContents: contents)
  }
}

In essence, the tests that I would like to write here would confirm that calling fetchPost would actually update my list of posts as new posts become available.

Planning the tests

I would probably call fetchPost to make sure that the feed state becomes a value that I expect, then I would call it again and return different posts from the network, making sure that my feed state updates accordingly. I would probably also want to test that if any error would be thrown during the fetching phase, that my feed state will become the corresponding error type.

So to boil that down to a list, here's the test I would write:

  • Make sure that I can fetch posts
  • Make sure that posts get updated if the network returns new posts
  • Make sure that errors are handled correctly

I also have the create post function, which is a little bit shorter. It doesn't change the feed state.

What I would test there is that if I create a post with certain contents, a post with the provided contents is actually what is returned from this function.

I've already implemented the networking layer for this view model, so here's what that looks like.

class NetworkClient {
  let urlSession: URLSession
  let baseURL: URL = URL(string: "https://practicalios.dev/")!

  init(urlSession: URLSession) {
    self.urlSession = urlSession
  }

  func fetchPosts() async throws -> [Post] {
    let url = baseURL.appending(path: "posts")
    let (data, _) = try await urlSession.data(from: url)

    return try JSONDecoder().decode([Post].self, from: data)
  }

  func createPost(withContents contents: String) async throws -> Post {
    let url = baseURL.appending(path: "create-post")
    var request = URLRequest(url: url)
    request.httpMethod = "POST"
    let body = ["contents": contents]
    request.httpBody = try JSONEncoder().encode(body)

    let (data, _) = try await urlSession.data(for: request)

    return try JSONDecoder().decode(Post.self, from: data)
  }
}

In an ideal world, I would be able to test that calling fetchPosts on my network client is actually going to construct the correct URL and that it will use that URL to make a call to URLSession. Similarly for createPost, I would want to make sure that the HTTP body that I construct is valid and contains the data that I intend to send to the server.

There are essentially two things that we could want to test here:

  1. The view model, making sure that it calls the correct functions of the network.
  2. The networking client, making sure that it makes the correct calls to the server.

Replacing your networking layer with a mock for testing

A common way to test code that relies on a network is to simply remove the networking portion of it altogether. Instead of depending on concrete networking objects, we would depend on protocols.

Abstracting our dependencies with protocols

Here's what that looks like if we apply this to our view model.

protocol Networking {
  func fetchPosts() async throws -> [Post]
  func createPost(withContents contents: String) async throws -> Post
}

@Observable
class FeedViewModel {
  var feedState: FeedState = .notLoaded
  private let network: any Networking

  init(network: any Networking) {
    self.network = network
  }

  // functions are unchanged
}

The key thing that changed here is that instead of depending on a network client, we depend on the Networking protocol. The Networking protocol defines which functions we can call and what the return types for those functions will be.

Since the functions that we've defined are already defined on NetworkClient, we can update our NetworkClient to conform to Networking.

class NetworkClient: Networking {
  // No changes to the implementation
}

In our application code, we can pretty much use this network client passage to our feed view model and nothing would really change. This is a really low-key way to introduce testability into our codebase for the feed view model.

Mocking the network in a test

Now let's go ahead and write a test that sets up our feed view model so that we can start testing it.

class MockNetworkClient: Networking {
  func fetchPosts() async throws -> [Post] {
    return []
  }

  func createPost(withContents contents: String) async throws -> Post {
    return Post(id: UUID(), contents: contents)
  }
}

struct FeedViewModelTests {
  @Test func testFetchPosts() async throws {
    let viewModel = FeedViewModel(network: MockNetworkClient())

    // we can now start testing the view model
  }
}

Now that we have a setup that we can test, it's time to take another look at our testing goals for the view model. These testing goals are what's going to drive our decisions for what we'll put in our MockNetworkClient.

Writing our tests

These are the tests that I wanted to write for my post fetching logic:

  • Make sure that I can fetch posts
  • Make sure that posts get updated if the network returns new posts
  • Make sure that errors are handled correctly

Let’s start adding them one-by-one.

In order to test whether I can fetch posts, my mock network should probably return some posts:

class MockNetworkClient: Networking {
  func fetchPosts() async throws -> [Post] {
    return [
      Post(id: UUID(), contents: "This is the first post"),
      Post(id: UUID(), contents: "This is post number two"),
      Post(id: UUID(), contents: "This is post number three")
    ]
  }

  // ...
}

With this in place, we can test our view model to see if calling fetchPosts will actually use this list of posts and update the feed state correctly.

@Test func testFetchPosts() async throws {
  let viewModel = FeedViewModel(network: MockNetworkClient())

  await viewModel.fetchPosts()

  guard case .loaded(let posts) = viewModel.feedState else {
    Issue.record("Feed state is not set to .loaded")
    return
  }

  #expect(posts.count == 3)
}

The second test would have us call fetchPosts twice to make sure that we update the list of posts in the view model.

In order for us to control our tests fully, we should probably have a way to tell the mock network what list of posts it should return when we call fetchPost. Let’s add a property to the mock that allows us to specify a list of posts to return from within our tests:

class MockNetworkClient: Networking {
  var postsToReturn: [Post] = []

  func fetchPosts() async throws -> [Post] {
    return postsToReturn
  }

  func createPost(withContents contents: String) async throws -> Post {
    return Post(id: UUID(), contents: contents)
  }
}

And now we can write our second test as follows:

@Test func fetchPostsShouldUpdateWithNewResponses() async throws {
  let client = MockNetworkClient()
  client.postsToReturn = [
    Post(id: UUID(), contents: "This is the first post"),
    Post(id: UUID(), contents: "This is post number two"),
    Post(id: UUID(), contents: "This is post number three")
  ]

  let viewModel = FeedViewModel(network: client)
  await viewModel.fetchPosts()

  guard case .loaded(let posts) = viewModel.feedState else {
    Issue.record("Feed state is not set to .loaded")
    return
  }

  #expect(posts.count == 3)

  client.postsToReturn = [
    Post(id: UUID(), contents: "This is a new post")
  ]

  await viewModel.fetchPosts()

  guard case .loaded(let posts) = viewModel.feedState else {
    Issue.record("Feed state is not set to .loaded")
    return
  }

  #expect(posts.count == 1)
}

The test is now more verbose but we are in full control over the responses that our mock network will provide.

Our third test for fetching posts is to make sure that errors are handled correctly. This means that we should apply another update to our mock. The goal is to allow us to define whether our call to fetchPosts should return a list of posts or throw an error. We can use Result for this:

class MockNetworkClient: Networking {
  var fetchPostsResult: Result<[Post], Error> = .success([])

  func fetchPosts() async throws -> [Post] {
    return try fetchPostsResult.get()
  }

  func createPost(withContents contents: String) async throws -> Post {
    return Post(id: UUID(), contents: contents)
  }
}

Now we can make our fetch posts calls succeed or fail as needed in the tests. Our tests would now need to be updated so that instead of just passing a list of posts to return, we're going to provide success with the list. Here's what that would look like for our first test (I’m sure you can update the longer test based on this example).

@Test func testFetchPosts() async throws {
  let client = MockNetworkClient()
  client.fetchPostsResult = .success([
    Post(id: UUID(), contents: "This is the first post"),
    Post(id: UUID(), contents: "This is post number two"),
    Post(id: UUID(), contents: "This is post number three")
  ])

  let viewModel = FeedViewModel(network: client)

  await viewModel.fetchPosts()

  guard case .loaded(let posts) = viewModel.feedState else {
    Issue.record("Feed state is not set to .loaded")
    return
  }

  #expect(posts.count == 3)
}

Data that we can provide a success or failure for our tests. We can actually go on ahead and tell our tests to throw a specific failure.

@Test func fetchPostsShouldUpdateWithErrors() async throws {
  let client = MockNetworkClient()
  let expectedError = NSError(domain: "Test", code: 1, userInfo: nil)
  client.fetchPostsResult = .failure(expectedError)

  let viewModel = FeedViewModel(network: client)
  await viewModel.fetchPosts()

  guard case .error(let error) = viewModel.feedState else {
    Issue.record("Feed state is not set to .error")
    return
  }

  #expect(error as NSError == expectedError)
}

We now have three tests that test our view model.

What's interesting about these tests is that they all depend on a mock network. This means that we're not relying on a network connection. But this also doesn't mean that our view model and network client are going to work correctly.

We haven't tested that our actual networking implementation is going to construct the exact requests that we expect it to create. In order to do this we can leverage something called URLProtocol.

Mocking responses with URLProtocol

Knowing that our view model works correctly is really good. However, we also want to make sure that the actual glue between our app and the server works correctly. That means that we should be testing our network client as well as the view model.

We know that we shouldn't be relying on the network in our unit tests. So how do we eliminate the actual network from our networking client?

One approach could be to create a protocol for URLSession and stuff everything out that way. It's an option, but it's not one that I like. I much prefer to use something called URLProtocol.

When we use URLProtocol to mock out our network, we can tell URLSession that we should be using our URLProtocol when it's trying to make a network request.

This allows us to take full control of the response that we are returning and it means that we can make sure that our code works without needing the network. Let's take a look at an example of this.

Before we implement everything that we need for our test, let's take a look at what it looks like to define an object that inherits from URLProtocol. I'm implementing a couple of basic methods that I will need, but there are other methods available on an object that inherits from URLProtocol.

I highly recommend you take a look at Apple's documentation if you're interested in learning about that.

Setting up ur URLProtocol subclass

For the tests that we are interested implementing, this is the skeleton class that I'll be working from:

class NetworkClientURLProtocol: URLProtocol {
  override class func canInit(with request: URLRequest) -> Bool {
    return true
  }

  override class func canonicalRequest(for request: URLRequest) -> URLRequest {
    return request
  }

  override func startLoading() {
    // we can perform our fake request here
  }
}

In the startLoading function, we're supposed to execute our fake network call and inform the client (which is a property that we inherit from URLProtocol) that we finished loading our data.

So the first thing that we need to do is implement a way for a user of our fake network to provide a response for a given URL. Again, there are many ways to go about this. I'm just going to use the most basic version that I can come up with to make sure that we don't get bogged down by details that will vary from project to project.

struct MockResponse {
  let statusCode: Int
  let body: Data
}

class NetworkClientURLProtocol: URLProtocol {
  // ...

  static var responses: [URL: MockResponse] = [:]
  static var validators: [URL: (URLRequest) -> Bool] = [:]
  static let queue = DispatchQueue(label: "NetworkClientURLProtocol")

  static func register(
    response: MockResponse, requestValidator: @escaping (URLRequest) -> Bool, for url: URL
  ) {
    queue.sync {
      responses[url] = response
      validators[url] = requestValidator
    }
  }

  // ...
}

By adding this code to my NetworkClientURLProtocol, I can register responses and a closure to validate URLRequest. This allows me to test whether a given URL results in the expected URLRequest being constructed by the networking layer. This is particularly useful when you’re testing POST requests.

Note that we need to make our responses and validators objects static. That's because we can't access the exact instance of our URL protocol that we're going to use before the request is made. So we need to register them statically and then later on in our start loading function we'll pull out the relevant response invalidator. We need to make sure that we synchronize this through a queue so we have multiple tests running in parallel. We might run into issues with overlap.

Before we implement the test, let’s complete our implementation of startLoading:

class NetworkClientURLProtocol: URLProtocol {
  // ...

  override func startLoading() {
    // ensure that we're good to...
    guard let client = self.client,
      let requestURL = self.request.url,
      let validator = validators[requestURL],
      let response = responses[requestURL]
    else { 
      Issue.record("Attempted to perform a URL Request that doesn't have a validator and/or response")
      return 
    }

        // validate that the request is as expected
    #expect(validator(self.request))

    // construct our response object
    guard let httpResponse = HTTPURLResponse(
      url: requestURL, 
      statusCode: response.statusCode, httpVersion: nil,
      headerFields: nil
    ) else {
      Issue.record("Not able to create an HTTPURLResponse")
      return 
    }

    // receive response from the fake network
    client.urlProtocol(self, didReceive: httpResponse, cacheStoragePolicy: .notAllowed)
    // inform the URLSession that we've "loaded" data
    client.urlProtocol(self, didLoad: response.body)
    // complete the request
    client.urlProtocolDidFinishLoading(self)
  }
}

The code contains comments on what we’re doing. While you might not have seen this kind of code before, it should be relatively self-explanatory.

Implementing a test that uses our URLProtocol subclass

Now that we’ve got startLoading implemented, let’s try and use this NetworkClientURLProtocol in a test…

class FetchPostsProtocol: NetworkClientURLProtocol { }

struct NetworkClientTests {
  func makeClient(with protocolClass: NetworkClientURLProtocol.Type) -> NetworkClient {
    let configuration = URLSessionConfiguration.default
    configuration.protocolClasses = [protocolClass]
    let session = URLSession(configuration: configuration)
    return NetworkClient(urlSession: session)
  }

  @Test func testFetchPosts() async throws {
    let networkClient = makeClient(with: FetchPostsProtocol.self)

    let returnData = try JSONEncoder().encode([
      Post(id: UUID(), contents: "This is the first post"),
      Post(id: UUID(), contents: "This is post number two"),
      Post(id: UUID(), contents: "This is post number three"),
    ])

    let fetchPostsURL = URL(string: "https://practicalios.dev/posts")!

    FetchPostsProtocol.register(
      response: MockResponse(statusCode: 200, body: returnData),
      requestValidator: { request in
        return request.url == fetchPostsURL
      },
      for: fetchPostsURL
    )

    let posts = try await networkClient.fetchPosts()
    #expect(posts.count > 0)
  }
}

The first thing I'm doing in this code is creating a new subclass of my NetworkClientProtocol. The reason I'm doing that is because I might have multiple tests running at the same time.

For that reason, I want each of my Swift test functions to get its own class. This might be me being a little bit paranoid about things overlapping in terms of when they are called, but I find that this creates a nice separation between every test that you have and the actual URLProtocol implementation that you're using to perform your assertions.

The goal of this test is to make sure that when I ask my network client to go fetch posts, it actually performs a request to the correct URL. And given a successful response that contains data in a format that’s expected from the server’s response, we're able to decode the response data into a list of posts.

We're essentially replacing the server in this example, which allows us to take full control over verifying that we're making the correct request and also have full control over whatever the server would return for that request.

Testing a POST request with URLProtocol

Now let’s see how we can write a test that makes sure that we’re sending the correct request when we’re trying to create a post.

struct NetworkClientTests {
  // ...

  @Test func testCreatePost() async throws {
    let networkClient = makeClient(with: CreatePostProtocol.self)

    // set up expected data
    let content = "This is a new post"
    let expectedPost = Post(id: UUID(), contents: content)
    let returnData = try JSONEncoder().encode(expectedPost)
    let createPostURL = URL(string: "https://practicalios.dev/create-post")!

    // register handlers
    CreatePostProtocol.register(
      response: MockResponse(statusCode: 200, body: returnData),
      requestValidator: { request in
        // validate basic setup
        guard 
          let httpBody = request.streamedBody,
          request.url == createPostURL,
          request.httpMethod == "POST" else {
            Issue.record("Request is not a POST request or doesn't have a body")
            return false
        }

        // ensure body is correct
        do {
          let decoder = JSONDecoder()
          let body = try decoder.decode([String: String].self, from: httpBody)
          return body == ["contents": content]
        } catch {
          Issue.record("Request body is not a valid JSON object")
          return false
        }
      },
      for: createPostURL
    )

    // perform network call and validate response
    let post = try await networkClient.createPost(withContents: content)
    #expect(post == expectedPost)
  }
}

There's quite a lot of code here, but overall it follows a pretty similar step to before. There's one thing that I want to call your attention to, and that is the line where I extract the HTTP body from my request inside of the validator. Instead of accessing httpBody, I'm accessing streamedBody. This is not a property that normally exists on URLRequest, so let's talk about why I need that for a moment.

When you create a URLRequest and execute that with URLSession, the httpBody that you assign is converted to a streaming body.

So when you access httpBody inside of the validator closure that I have, it's going to be nil.

Instead of accessing that, we need to access the streaming body, gather the data, and return alll data.

Here's the implementation of the streamedBody property that I added in an extension to URLRequest:

extension URLRequest {
  var streamedBody: Data? {
    guard let bodyStream = httpBodyStream else { return nil }
    let bufferSize = 1024
    let buffer = UnsafeMutablePointer<UInt8>.allocate(capacity: bufferSize)
    var data = Data()
    bodyStream.open()
    while bodyStream.hasBytesAvailable {
      let bytesRead = bodyStream.read(buffer, maxLength: bufferSize)
      data.append(buffer, count: bytesRead)
    }
    bodyStream.close()
    return data
  }
}

With all this in place, I'm able to now check that my network client constructs a fully correct network request that is being sent to the server and that if the server responds with a post like I expect, I'm actually able to handle that.

So at this point, I have tests for my view model (where I mock out the entire networking layer to make sure that the view model works correctly) and I have tests for my networking client to make sure that it performs the correct requests at the correct times.

In Summary

Testing code that has dependencies is always a little bit tricky. When you have a dependency you'll want to mock it out, stub it out, remove it or otherwise hide it from the code that you're testing. That way you can purely test whether the code that you're interested in testing acts as expected.

In this post we looked at a view model and networking object where the view model depends on the network. We mocked out the networking object to make sure that we could test our view model in isolation.

After that we also wanted to write some tests for the networking object itself. To do that, we used a URLProtocol object. That way we could remove the dependency on the server entirely and fully run our tests in isolation. We can now test that our networking client makes the correct requests and handles responses correctly as well.

This means that we now have end-to-end testing for a view model and networking client in place.

I don’t often leverage URLProtocol in my unit tests; it’s mainly in complex POST requests or flows that I’m interested in testing my networking layer this deeply. For simple requests I tend to run my app with Proxyman attached and I’ll verify that my requests are correct manually.

Categories

Testing

Subscribe to my newsletter