If we want to unit test the AddLocationViewModel class, we need the ability to stub the responses of the geocoding requests we make to Apple's location services. Only then can we write fast and reliable unit tests. Being in control of your environment is essential if your goal is creating a fast and robust test suite.

Not only do we want to be in control of the response we receive from the geocoding requests, we don't want the test suite to rely on a service we don't control. It can make the test suite slow and unreliable.

How do we stub the responses of the geocoding requests the application makes? The Core Location framework is a system framework. We cannot mock the CLGeocoder class. The solution isn't difficult, but it requires a bit of work.

A Plan of Action

The solution involves three steps:

First, we need to create a service in charge of performing the geocoding requests. That service is injected into the view model because the view model shouldn't be responsible for instantiating the service.

Second, the service we inject into the view model conforms to a protocol we define. The protocol is nothing more than a definition of an interface that allows the view model to initiate a geocoding request. It initiates the geocoding request, it doesn't perform the geocoding request.

Third, the service conforms to the protocol and we inject an instance of the service into the view model.

Not only does this solution decouple the view model from the Core Location framework, the view model won't even know which service it is using. The view model only cares that the service conforms to the protocol we define.

Don't worry if this sounds confusing. Let's start by creating the protocol for the service. We can draw inspiration from the current implementation of the AddLocationViewModel class. It shows us exactly what the protocol should look like.

Defining the Protocol

Create a new file in the Protocols group and name it LocationService.swift.

Creating the Location Service

We can keep the protocol's definition short. We only define the interface we need to extract the CLGeocoder class from the view model.

LocationService.swift

protocol LocationService {

}

We first define a type alias with name, Completion. The type of the type alias is a closure that accept an argument of type Result and returns Void. The associated value of the success case of the Result type is of type [Location]. The associated value of the failure case of the Result type is of type LocationServiceError.

LocationService.swift

protocol LocationService {

    // MARK: - Type Aliases

    typealias Completion = (Result<[Location], LocationServiceError>) -> Void

}

The next step is defining the LocationServiceError enum. Because types cannot be nested within the a protocol definition, we define the LocationServiceError enum below the LocationService protocol. The enum conforms to the Error protocol and defines two cases, invalidAddressString and requestFailed. The requestFailed case has an associated value of type Error. Why that is becomes clear in a moment.

protocol LocationService {

    // MARK: - Type Aliases

    typealias Completion = (Result<[Location], LocationServiceError>) -> Void

}

// MARK: - Types

enum LocationServiceError: Error {

    // MARK: - Cases

    case invalidAddressString
    case requestFailed(Error)

}

The LocationService protocol defines a single method. The method performs the geocoding request and is named geocode(addressString:completion:). The method accepts an address string and a closure. Because the geocoding request is performed asynchronously, we mark the closure as escaping by annotating it with the escaping attribute.

LocationService.swift

protocol LocationService {

    // MARK: - Type Aliases

    typealias Completion = (Result<[Location], LocationServiceError>) -> Void

    // MARK: - Methods

    func geocode(addressString: String, completion: @escaping Completion)

}

// MARK: - Types

enum LocationServiceError: Error {

    // MARK: - Cases

    case invalidAddressString
    case requestFailed(Error)

}

Adopting the Protocol

With the LocationService protocol in place, it is time to create a class that adopts the LocationService protocol. Create a new group, Services, and create a file with name Geocoder.swift.

Creating the Geocoder

Because we use the CLGeocoder class to perform the geocoding requests, we need to import the Core Location framework at the top.

Geocoder.swift

import CoreLocation

We define a class with name Geocoder that conforms to the LocationService protocol we defined earlier.

Geocoder.swift

import CoreLocation

class Geocoder: LocationService {

}

The Geocoder class has one private, lazy, variable property, geocoder, of type CLGeocoder.

Geocoder.swift

import CoreLocation

class Geocoder: LocationService {

    // MARK: - Properties

    private lazy var geocoder = CLGeocoder()

}

The only thing left to do is implement the method of the LocationService protocol. This isn't difficult since most of the implementation can be found in the current implementation of the AddLocationViewModel class.

Geocoder.swift

import CoreLocation

class Geocoder: LocationService {

    // MARK: - Properties

    private lazy var geocoder = CLGeocoder()

    // MARK: - Location Service

    func geocode(addressString: String, completion: @escaping Completion) {
        guard !addressString.isEmpty else {
            completion(.failure(.invalidAddressString))
            return
        }

        // Geocode Address String
        geocoder.geocodeAddressString(addressString) { (placemarks, error) in
            if let error = error {
                completion(.failure(.requestFailed(error)))
            } else if let placemarks = placemarks {
                // Create Locations
                let locations = placemarks.compactMap({ (placemark) -> Location? in
                    guard let name = placemark.name else { return nil }
                    guard let location = placemark.location else { return nil }
                    return Location(name: name, latitude: location.coordinate.latitude, longitude: location.coordinate.longitude)
                })

                completion(.success(locations))
            }
        }
    }

}

This should look familiar. The implementation is clear and elegant thanks to the use of the Result type. The Result type makes it possible to convert an inelegant Objective-C API into an elegant Swift API.

We now have the ingredients we need to refactor the AddLocationViewController and AddLocationViewModel classes. Let's start with the AddLocationViewModel class.

Refactoring the Add Location View Model

Open AddLocationViewModel.swift and replace the geocoder property with a constant property, locationService, of type LocationService.

AddLocationViewModel.swift

// MARK: -

private let locationService: LocationService

This simple change means that the AddLocationViewModel class no longer knows how the application performs the geocoding requests. It could be the Core Location framework, but it might as well be some other library. This will come in useful later. It also means we can remove the import statement for the Core Location framework at the top.

AddLocationViewModel.swift

import Combine
import Foundation

class AddLocationViewModel {

    ...

}

We inject a location service into the view model using initializer injection. We pass an argument to the initializer of the AddLocationViewModel class. The only requirement for the argument is that it conforms to the LocationService protocol. We set the locationService property in the initializer.

AddLocationViewModel.swift

// MARK: - Initialization

init(locationService: LocationService) {
    // Set Properties
    self.locationService = locationService

    // Setup Bindings
    setupBindings()
}

We also need to update the geocode(addressString:) method of the AddLocationViewModel class. Because most of the heavy lifting is handled by the location service, the implementation is shorter and simpler.

AddLocationViewModel.swift

// MARK: - Helper Methods

private func geocode(addressString: String?) {
    guard let addressString = addressString else {
        // Reset Locations
        locations = []

        return
    }

    // Update Helper
    querying = true

    // Geocode Address String
    locationService.geocode(addressString: addressString) { [weak self] (result) in
        // Update Helper
        self?.querying = false

        switch result {
        case .success(let locations):
            self?.locations = locations
        case .failure(let error):
            self?.locations = []

            print("Unable to Forward Geocode Address (\(error)")
        }
    }
}

Refactoring the Add Location View Controller

The change we need to make in the AddLocationViewController class is small. Open AddLocationViewController.swift and navigate to the viewDidLoad() method. We only need to update the initialization of the view model in the viewDidLoad() method. The view controller creates a Geocoder instance and passes it to the initializer of the AddLocationViewModel class.

AddLocationViewModel.swift

// Initialize View Model
viewModel = AddLocationViewModel(locationService: Geocoder())

That's it. Build and run the application to make sure we didn't break anything. The AddLocationViewModel class is now ready to be unit tested.