Mastering MVVM With Swift

Protocol Oriented Programming and Dependency Injection

If we want to test the AddLocationViewViewModel 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 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.

But how do we stub the responses of the geocoding requests we make? The Core Location framework is a system framework. We cannot mock the CLGeocoder class. The solution is simple, but it requires a bit of work.

A Plan of Action

The solution involves three steps:

First, we need to create a service that's in charge of performing the geocoding requests. That service needs to be injected into the view model. The view model shouldn't be in charge of 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's using, that is, as long as 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 AddLocationViewViewModel class. It shows us what the protocol should look like.

Defining the Protocol

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

Creating LocationService.swift

The protocol's definition will be 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, LocationServiceCompletionHandler. This is primarily for convenience.

LocationService.swift

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

More important 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. The completion handler is of type LocationServiceCompletionHandler.

LocationService.swift

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

Adopting the Protocol

With the LocationService protocol in place, it's time to create a class that adopts the LocationService protocol. Create a new group, Services, and create a class named Geocoder. You can name it whatever you like.

Creating Geocoder.swift

Because we're going to use the CLGeocoder class to perform the geocoding requests, we need to import the Core Location framework.

Geocoder.swift

import CoreLocation

We define the Geocoder class. The class should conform 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 AddLocationViewViewModel class.

Geocoder.swift

// 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
        if let error = error {
            completionHandler([], error)
            print("Unable to Forward Geocode Address (\(error))")

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

Refactoring the Add Location View View Model

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

AddLocationViewViewModel.swift

// MARK: -

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 some other library. This will come in handy later. It also means we can remove the import statement for the Core Location framework.

AddLocationViewViewModel.swift

import RxSwift
import RxCocoa
import Foundation

class AddLocationViewViewModel {

    ...

}

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

AddLocationViewViewModel.swift

// MARK: - Initialization

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

    query
        .throttle(0.5)
        .distinctUntilChanged()
        .drive(onNext: { [weak self] (addressString) in
            self?.geocode(addressString: addressString)
        })
        .disposed(by: disposeBag)
}

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 shorter and simpler.

AddLocationViewViewModel.swift

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

    _querying.accept(true)

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

        if let error = error {
            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 line on which we initialize the view model.

AddLocationViewViewModel.swift

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

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

Next Episode "Testing and Mocking"

Stop Writing Swift That Sucks

Download the Swift Patterns I Swear By