The MockClient class fetches its mock data from the application bundle. This works fine, but there are a few limitations. The most important limitation is the lack of flexibility. With the current setup, we can only test the happy path, that is, the application showing the user a list of episodes.

What happens if the application isn't able to fetch data from the Cocoacasts API? When that happens, the application should show an error message of some sort to inform the user that something didn't go as planned. Being able to simulate these scenarios during development is important to make sure you always deliver an acceptable user experience.

Defining Endpoints

I would like to add the ability to define how the MockClient class responds. This isn't as difficult as you might think. Let's revisit MockClient.swift. We start by defining a nested enum with name Response. A Response object defines how the mock client responds to a request. The implementation is simple. The Response enum defines two cases, success and failure. Each case has an associated value. The success case has an associated value of type URL. The failure case has an associated value of type APIError.

import Foundation

final class MockClient: APIClient {

    // MARK: - Types

   enum Response {

        // MARK: - Cases

        case success(URL)
        case failure(APIError)

    }

    ...

}

We need the ability to define what type of response the MockClient class should return for a given endpoint. We first define a private, variable property, endpoints, of type [APIEndpoint: Response]. The initial value of the endpoints property is an empty dictionary.

import Foundation

final class MockClient: APIClient {

    // MARK: - Types

   enum Response {

        // MARK: - Cases

        case success(URL)
        case failure(APIError)

    }

    // MARK: - Properties

    private var endpoints: [APIEndpoint: Response] = [:]

    ...

}

The next step is implementing a convenience method to add an entry to the endpoints property. We define a method with name add(response:for:). The method accepts a Response object as its first argument and an APIEndpoint object as its second argument. In the body of the method, the MockClient instance updates the endpoints property. Like I said, the add(response:for:) method is a convenience method to hide the endpoints property from other objects.

func add(response: Response, for endpoint: APIEndpoint) {
    // Add to Endpoints
    endpoints[endpoint] = response
}

Let's update the implementation of the fetchEpisodes(_:) method. We ask the endpoints property for the Response object for the episodes endpoint. We use a guard statement to safely access the Response object. The fetchEpisodes(_:) method returns early if the endpoints property has no Response object for the episodes endpoint. It invokes the completion handler, passing it a requestFailed error.

func fetchEpisodes(_ completion: @escaping (Result<[Episode], APIError>) -> Void) {
    guard let response = endpoints[.episodes] else {
        completion(.failure(.requestFailed))
        return
    }
}

We use a switch statement to switch on the Response object, adding a case for success and a case for failure. The failure case is easy to implement. The fetchEpisodes(_:) method invokes the completion handler, passing it the APIError object that is associated with the failure case.

func fetchEpisodes(_ completion: @escaping (Result<[Episode], APIError>) -> Void) {
    guard let response = endpoints[.episodes] else {
        completion(.failure(.requestFailed))
        return
    }

    switch response {
    case .success(let url):
        break
    case .failure(let error):
        completion(.failure(error))
    }
}

Handling the success case isn't difficult either because we can reuse bits from the original implementation of the fetchEpisodes(_:) method. We load the mock data, decode it, and pass the array of Episode objects to the completion handler. This should look familiar if you have watched the previous episode of this series.

func fetchEpisodes(_ completion: @escaping (Result<[Episode], APIError>) -> Void) {
    guard let response = endpoints[.episodes] else {
        completion(.failure(.requestFailed))
        return
    }

    switch response {
    case .success(let url):
        // Load Mock Response
        guard let data = try? Data(contentsOf: url) else {
            fatalError("Unable to Load Mock Response")
        }

        // Initialize JSON Decoder
        let decoder = JSONDecoder()

        // Configure JSON Decoder
        decoder.dateDecodingStrategy = .iso8601
        decoder.keyDecodingStrategy = .convertFromSnakeCase

        // Decode JSON Response
        guard let episodes = try? decoder.decode([Episode].self, from: data) else {
            fatalError("Unable to Decode Mock Response")
        }

        // Invoke Handler
        completion(.success(episodes))
    case .failure(let error):
        completion(.failure(error))
    }
}

Let's try it out. Open Environment.swift and navigate to the apiClient static computed property. We create a MockClient instance and store a reference in a constant with name mockClient.

// MARK: - API Client

static var apiClient: APIClient {
    switch current {
    case .development:
        // Create Mock Client
        let mockClient = MockClient()
    default:
        return CocoacastsClient(apiKey: apiKey, baseUrl: baseUrl)
    }
}

We need to configure the MockClient instance before we can put it to use. I have uploaded a mock response to a remote server. We define the URL and store it in a constant with name url. Notice that we forced unwrap the result of the initialization since we only use the MockClient class in the development environment. I prefer readability over safety in the development environment.

With the URL object in place, we invoke the add(response:for:) method, passing in the URL object and the episodes endpoint. By storing the URL object in a local constant, the implementation becomes more readable. Don't forget to return the MockClient instance.

// MARK: - API Client

static var apiClient: APIClient {
    switch current {
    case .development:
        // Create Mock Client
        let mockClient = MockClient()

        // Configure Endpoints
        let url = URL(string: "https://cocoacasts.s3.us-west-1.amazonaws.com/mock-api/episodes.json")!
        mockClient.add(response: .success(url), for: .episodes)

        return mockClient
    default:
        return CocoacastsClient(apiKey: apiKey, baseUrl: baseUrl)
    }
}

This looks good, but it quickly becomes messy and difficult to read if we define several more response-endpoint combinations. At the bottom of Environment.swift, define a private enum with name MockAPI. We define a nested enum with name Episodes in the MockAPI enum. The Episodes enum defines a single static, constant property success. We assign the URL object we created earlier to success.

private enum MockAPI {

    enum Episodes {

        static let success = URL(string: "https://cocoacasts.s3.us-west-1.amazonaws.com/mock-api/episodes.json")!

    }

}

It doesn't matter which strategy you use. The goal is to have a system in place that adds some structure and readability. The implementation of the apiClient static computed property becomes tidier and easier to read if we put the MockAPI enum to use.

// MARK: - API Client

static var apiClient: APIClient {
    switch current {
    case .development:
        // Create Mock Client
        let mockClient = MockClient()

        // Configure Endpoints
        mockClient.add(response: .success(MockAPI.Episodes.success), for: .episodes)

        return mockClient
    default:
        return CocoacastsClient(apiKey: apiKey, baseUrl: baseUrl)
    }
}

Build and run the application to see the result. The MockClient class fetches its data from a remote location and no longer relies on the mock data that is included in the application bundle.

What's Next?

We successfully added a bit of flexibility to the MockClient class, but there's room for improvement. The MockClient class blocks the main thread when it fetches the mock data from the remote server. This isn't a major issue during development, but I would like to address it.

There's another issue. The implementation of the fetchEpisodes(_:) method works fine, but it isn't optimized. What do I mean by that? If we want to add support for more endpoints, then we inevitably end up with code duplication. We fix this in the next episode.