Using a local server is a convenient solution during development because it makes it straightforward to set up a development environment. It also allows for flexibility, making it easy to tweak the implementation of the backend as you develop the client.

The drawback is obvious, though. The client application only shows data if it is connected to the local server or if it has permission to connect to the staging or production server. In this episode, we revisit the power of protocol-oriented programming to create a mock server for the development environment.

Using a Mock Server

As the name suggests, a mock server mimics the behavior of a local or remote server. A mock server is often used for testing purposes and that is something we explore later in this series. In this episode, I want to show you how to use a mock server for replacing a local server. This means that you don't need to run an instance of the local server to run the Cocoacasts client. At the end of this episode, you are able to download the source files of the Cocoacasts client and run it in the simulator.

Creating a Protocol

Create a new group in the Networking > APIClient group and name it Protocols. Add a Swift file to the Protocols group and name it APIClient.swift. We define an internal protocol with name APIClient that extends AnyObject. By extending AnyObject, types conforming to the APIClient protocol are required to be classes, that is, reference types.

import Foundation

internal protocol APIClient: AnyObject {

}

Remember that we already have a file with name APIClient.swift and a final class with name APIClient in the Networking > APIClient group. The compiler won't like this. Let's rename the APIClient class. Create a new group with name Clients in the Networking > APIClient group and move APIClient.swift to the Clients group. Rename APIClient.swift to CocoacastsClient.swift and rename the APIClient class to the CocoacastsClient class. Last but not least, we conform the CocoacastsClient class to the APIClient protocol.

import Foundation
import CocoaLumberjack

final class CocoacastsClient: APIClient {

    ...

}

Before we implement the APIClient protocol, we need to move some of the code of the CocoacastsClient class. Create a group with name Types in the Networking > APIClient group. Add a Swift file with name APIError.swift to the Types group and open CocoacastsClient.swift on the right of APIError.swift. Move the APIClientError enum from CocoacastsClient.swift to APIError.swift and rename it to APIError. Define it as an internal enum.

import Foundation

internal enum APIError: Error {

    // MARK: - Cases

    case requestFailed
    case invalidResponse

}

In the CocoacastsClient class, we need to rename APIClientError to APIError in the fetchEpisodes(_:) method.

// MARK: - Public API

func fetchEpisodes(_ completion: @escaping (Result<[Episode], APIError>) -> Void) {
    ...
}

Add another Swift file with name APIEndpoint.swift to the Types group and open CocoacastsClient.swift on the right of APIEndpoint.swift. Move the Endpoint enum from CocoacastsClient.swift to APIEndpoint.swift and rename it to APIEndpoint. Define it as an internal enum.

import Foundation

internal enum APIEndpoint: String {

    // MARK: - Cases

    case episodes

    // MARK: - Properties

    var path: String {
        return rawValue
    }

}

In the CocoacastsClient class, we need to rename Endpoint to APIEndpoint in the request(for:) method.

// MARK: - Helper Methods

private func request(for endpoint: APIEndpoint) -> URLRequest {
    ...
}

Implementing the APIClient Protocol

Open APIClient.swift on the left and CocoacastsClient.swift on the right. Copy the method signature of the fetchEpisodes(_:) method in CocoacastsClient.swift and add it to the APIClient protocol in APIClient.swift.

import Foundation

internal protocol APIClient: AnyObject {

    func fetchEpisodes(_ completion: @escaping (Result<[Episode], APIError>) -> Void)

}

Updating the Environment

Open FeedCoordinator.swift and navigate to the feedViewController property. We currently create an APIClient instance by asking the Environment enum for the API key and the base URL.

private lazy var feedViewController: FeedViewController = {
    // Initialize API Client
    let apiClient = APIClient(apiKey: Environment.apiKey, baseUrl: Environment.baseUrl)

    ...
}()

This is fine, but I'd like to encapsulate the creation of the APIClient instance in the Environment enum. Why is that? I want to make sure the FeedCoordinator class doesn't know how the APIClient is instantiated. Let me show you what I mean.

Open Environment.swift and declare the apiKey and baseUrl static properties privately. That is the first step.

import Foundation

enum Environment: String {

    // MARK: - Environments

    case staging
    case production
    case development

    ...

    // MARK: - Base URL

    private static var baseUrl: URL {
        ...
    }

    // MARK: - API Key

    private static var apiKey: String {
        ...
    }

    ...

}

The next step is defining a static property, apiClient, of type APIClient. We keep the implementation straightforward for the time being. We initialize an instance of the CocoacastsClient class and return it.

import Foundation

enum Environment: String {

    // MARK: - Environments

    case staging
    case production
    case development

    ...

    // MARK: - Base URL

    private static var baseUrl: URL {
        ...
    }

    // MARK: - API Key

    private static var apiKey: String {
        ...
    }

    // MARK: - API Client

    static var apiClient: APIClient {
        return CocoacastsClient(apiKey: apiKey, baseUrl: baseUrl)
    }

    ...

}

Let's revisit the FeedCoordinator class. The feed coordinator no longer creates the APIClient instance. It asks the Environment enum for an APIClient instance instead.

private lazy var feedViewController: FeedViewController = {
    // Initialize API Client
    let apiClient = Environment.apiClient

    ...
}()

Creating a Mock Client

It's time to take advantage of the groundwork we laid in this episode. Add a Swift file to the Networking > APIClient > Clients group and name it MockClient.swift. Define a final class with name MockClient that conforms to the APIClient protocol.

/**
 The `MockClient` class can be used to locally
 test the Cocoacasts application without
 the need for a local server.
 */

import Foundation

final class MockClient: APIClient {

}

We implement the fetchEpisodes(_:) method in a moment. Before we continue, we need to add the mock data for the /episodes endpoint, that is, the data the MockClient class returns. Create a new group in the Resources group and name it Mock API. We add the mock data for the /episodes endpoint to the Mock API group and name it episodes.json. You can find the mock data in the finished project of this episode.

Conforming to the APIClient Protocol

The next step is implementing the fetchEpisodes(_:) method in the MockClient class in MockClient.swift.

/**
 The `MockClient` class can be used to locally
 test the Cocoacasts application without
 the need for a local server.
 */

import Foundation

final class MockClient: APIClient {

    // MARK: - API Client

    func fetchEpisodes(_ completion: @escaping (Result<[Episode], APIError>) -> Void) {

    }

}

We make ample use of fatal errors for severals reasons. Loading the mock data and passing it to the completion handler of the fetchEpisodes(_:) method should not fail. Keep in mind that we only use the MockClient class during development.

We ask the main bundle for the URL of episodes.json, the mock data we added a few moments ago. We use a guard statement and throw a fatal error if the MockClient instance isn't able to obtain the URL for episodes.json. This should never happen.

// MARK: - API Client

func fetchEpisodes(_ completion: @escaping (Result<[Episode], APIError>) -> Void) {
    // Find Mock Response
    guard let url = Bundle.main.url(forResource: "episodes", withExtension: "json") else {
        fatalError("Unable to Find Mock Response")
    }
}

We use the URL to create a Data object using the try? keyword and a guard statement. We throw a fatal error if the MockClient instance isn't able to load the mock data from the application bundle.

// MARK: - API Client

func fetchEpisodes(_ completion: @escaping (Result<[Episode], APIError>) -> Void) {
    // Find Mock Response
    guard let url = Bundle.main.url(forResource: "episodes", withExtension: "json") else {
        fatalError("Unable to Find Mock Response")
    }

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

We initialize an instance of the JSONDecoder class to convert the Data object to an array of Episode objects. We set the date decoding strategy of the JSON decoder to iso8601 and the key decoding strategy to convertFromSnakeCase. This isn't new. The JSON decoder used in the CocoacastsClient class is configured identically.

We ask the JSONDecoder instance to decode the Data object. The result should be an array of Episode objects. We use the try? keyword in combination with a guard statement and throw another fatal error if the MockClient instance isn't able to decode the mock data.

// MARK: - API Client

func fetchEpisodes(_ completion: @escaping (Result<[Episode], APIError>) -> Void) {
    // Find Mock Response
    guard let url = Bundle.main.url(forResource: "episodes", withExtension: "json") else {
        fatalError("Unable to Find Mock Response")
    }

    // 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")
    }
}

The array of episodes is passed to the completion handler of the fetchEpisodes(_:) method.

// MARK: - API Client

func fetchEpisodes(_ completion: @escaping (Result<[Episode], APIError>) -> Void) {
    // Find Mock Response
    guard let url = Bundle.main.url(forResource: "episodes", withExtension: "json") else {
        fatalError("Unable to Find Mock Response")
    }

    // 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))
}

Putting the Mock Client to Use

Open Environment.swift. It's time to put the MockClient class to use. Navigate to the apiClient static property and add a switch statement. If the current environment is the development environment, then we initialize and return an instance of the MockClient class. For the staging and production environment, we initialize and return an instance of the CocoacastsClient class.

// MARK: - API Client

static var apiClient: APIClient {
    switch current {
    case .development:
        return MockClient()
    default:
        return CocoacastsClient(apiKey: apiKey, baseUrl: baseUrl)
    }
}

Select the Cocoacasts/Development scheme at the top. Build and run the application to see the result. The Feed tab should display the mock data we added to the Mock API group.

Using the Mock Server

Adding Flexibility

The mock data is currently being stored in the application bundle, but that is only an example. It is also possible to ask the MockClient class to fetch the mock data from a remote location. We take a look at that approach in the next episode.