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.