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.