Remember from the previous installment of this series that our goal is to mock the Core Location framework and stub the responses of any geocoding requests we send to Apple's location services. This is necessary if we want to create a fast and reliable test suite. Being in control of the test environment is key if your goal is creating a 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 unit tests of the AddLocationViewViewModel class to rely on a service we don't control. It can make the test suite slow and unreliable.

Remember that we have three tasks to complete:

  • define a protocol for a service that performs the geocoding requests
  • create a service that conforms to this protocol
  • inject the service into the view model

Not only will we decouple the view model from the Core Location framework with this solution, the view model won't even know which service it is using, as long as the service conforms to the LocationService protocol.

Don't worry if it doesn't click yet. Let's start by creating the LocationService protocol. We can draw inspiration from the current implementation of the AddLocationViewViewModel class. This class shows us what the protocol should look like.

Step 1: Defining the LocationService Protocol

We create a new file and name it LocationService.swift. The protocol definition is short because we only define the interface we need to extract the CLGeocoder class from the AddLocationViewViewModel class.

protocol LocationService {

    typealias LocationServiceCompletionHandler = ([Location], Error?) -> Void

    func geocode(addressString: String?, completionHandler: @escaping LocationServiceCompletionHandler)

}

We define a type alias, LocationServiceCompletionHandler, for convenience. It accepts an array of Location instances and an optional Error instance. More interesting is the definition of the method that performs the geocoding request, geocode(addressString:completionHandler:). It accepts an address string and a completion handler.

Because the geocoding request is performed asynchronously, we mark the completion handler as escaping with the escaping attribute. The completion handler is of type LocationServiceCompletionHandler.

Step 2: Creating the Geocoder Class

The next step is creating a class that conforms to the LocationService protocol. We start by creating a new file, Geocoder.swift. Because we're going to use the CLGeocoder class to perform the geocoding requests, we need to import the Core Location framework.

import CoreLocation

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

import CoreLocation

class Geocoder: LocationService {

}

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

import CoreLocation

class Geocoder: LocationService {

    // MARK: - Properties

    private lazy var geocoder = CLGeocoder()

}

The only thing left for us 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 AddLocationViewViewModel class.

import CoreLocation

class Geocoder: LocationService {

    // MARK: - Properties

    private lazy var geocoder = CLGeocoder()

    // MARK: - Location Service Protocol

    func geocode(addressString: String?, completionHandler: @escaping LocationService.LocationServiceCompletionHandler) {
        guard let addressString = addressString else {
            completionHandler([], nil)
            return
        }

        // Geocode Address String
        geocoder.geocodeAddressString(addressString) { (placemarks, error) in
            var locations: [Location] = []

            if let error = error {
                print("Unable to Forward Geocode Address (\(error))")

            } else if let _placemarks = placemarks {
                // Update Locations
                locations = _placemarks.flatMap({ (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)
                })
            }

            completionHandler(locations, nil)
        }
    }

}

This should look familiar. The only difference is that we pass the array of Location instances to the completion handler of the method along with any errors that pop up. We now have the ingredients we need to refactor the AddLocationViewController and AddLocationViewViewModel classes. Let's start with the AddLocationViewViewModel class.

Step 3: Injecting the Location Service

In the AddLocationViewViewModel class, we replace the geocoder property with a private, constant property, locationService, of type LocationService.

private let locationService: LocationService

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

We inject a location service into the view model using initializer injection. We update the current initializer by defining a new parameter, locationService. The only requirement for the parameter is that it conforms to the LocationService protocol. In the body of the initializer, we assign the value of the locationService parameter to the locationService property.

// MARK: - Initialization

init(query: Driver<String>, locationService: LocationService) {
    // Set Location Service
    self.locationService = locationService

    ...
}

We also need to update the geocode(addressString:) method of the AddLocationViewViewModel class. Because most of the heavy lifting is done by the location service, the implementation is short.

private func geocode(addressString: String?) {
    guard let addressString = addressString, addressString.characters.count > 2 else {
        _locations.value = []
        return
    }

    _querying.value = true

    // Geocode Address String
    locationService.geocode(addressString: addressString) { [weak self] (locations, error) in
        self?._querying.value = false
        self?._locations.value = locations

        if let error = error {
            print("Unable to Forward Geocode Address (\(error))")
        }
    }
}

We also need to update the AddLocationViewController class. The change we need to make to the viewDidLoad() method of the AddLocationViewController class is small. In viewDidLoad(), we update the line on which we initialize the view model.

// Initialize View Model
viewModel = AddLocationViewViewModel(query: searchBar.rx.text.orEmpty.asDriver(), locationService: Geocoder())

That's it. The AddLocationViewViewModel class is now ready to be tested.

What's Next

In the next installment of this series, we write a few unit tests for the AddLocationViewViewModel class. It will show you how to create a mock location service. This won't be difficult since we already laid the foundation to make that possible.