Speed and reliability are key elements of a robust test suite. But for a test suite to be fast and reliable, you need to be in control of the environment in which the test suite is run. You don't want to write a unit test that depends on the response of an API request. Right? For such a unit test to be fast and reliable, the API your application interacts with needs to be mocked and its response stubbed.
The Objective-C runtime makes mocking and stubbing easy. But Swift isn't Objective-C. Swift differs in many respects that make mocking and stubbing less straightforward. Don't worry, though. You already have the tools you need to write fast and reliable unit tests, even if your application interacts with services and frameworks you don't control.
In this series, I show you how to mock and stub dependencies you don't control, including system services. We leverage dependency injection and protocol-oriented programming to mock and stub a component of the Core Location framework.
The example I use in this series is taken from Mastering MVVM With Swift in which we refactor a weather application, Cloudy. One of Cloudy's features is the ability to switch between locations. The user can add a location to its favorites by entering the name of a city. The application uses the CLGeocoder
class of the Core Location framework to forward geocode the city's name. To fetch the weather data for a city, the application needs a set of coordinates. The CLGeocoder
class helps us obtain the coordinates of a location.
Mocking and Stubbing Core Location
The view model we'd like to test uses a CLGeocoder
instance to forward geocode the name of the city entered by the user. If we want to unit test the view model, we need the ability to mock the CLGeocoder
instance and the response the Core Location framework returns. But why is that important? Why do we need to mock the CLGeocoder
instance?
Reliability
If we want the unit tests for the view model to be fast and reliable, we need to avoid that any network requests are performed while the unit tests are run. If we ask the Core Location framework to forward geocode an address string, it contacts Apple's location services. While I don't doubt the speed and reliability of Apple's services, it introduces a risk.
Not only does it introduce a dependency, it also presents a liability. If the machine running the test suite has a poor network connection, the unit tests may take longer to run or, even worse, they may fail.
Control
We need to control every variable if we want to create a robust, reliable test suite. What do I mean by that? A unit test tests a tiny aspect of an application. The result of the unit test depends on the state and behavior of the application. The state and behavior of the application need to be consistent every time we run the same unit test. Only then can we create a reliable unit test. We don't want to have a unit test that passes most of the time.
But that implies we need to be in control of the application's state and behavior. Being in control of state isn't a problem mostly. Being in control of the application's behavior is less evident. To be in control of the application's state and behavior, we need to control how it interacts with and responds to its environment. And that often means we need to control the services it interacts with, such as Apple's location services.
What's Possible
It's important to understand what we can and cannot do. We won't be able to control what Apple's location services return. We may be able to intercept the response of the forward geocoding request, but that's not the goal we're after. We can't mock the CLGeocoder
class. While this is possible if you dig into the Objective-C runtime, this currently isn't possible using pure Swift.
You may be starting to wonder why you're still reading this tutorial. Don't worry, though. The idea is simple but surprisingly powerful. Before I show you the solution, I'd like to show you what the view model currently looks like. Even though the AddLocationViewViewModel
class leverages RxSwift, there's no need to be familiar with reactive programming to understand this tutorial.
import RxSwift
import RxCocoa
import CoreLocation
class AddLocationViewViewModel {
// MARK: - Properties
var querying: Driver<Bool> { return _querying.asDriver() }
var locations: Driver<[Location]> { return _locations.asDriver() }
// MARK: -
private let _querying = Variable<Bool>(false)
private let _locations = Variable<[Location]>([])
// MARK: -
var hasLocations: Bool { return numberOfLocations > 0 }
var numberOfLocations: Int { return _locations.value.count }
// MARK: -
private lazy var geocoder = CLGeocoder()
// MARK: -
private let disposeBag = DisposeBag()
// MARK: - Initializtion
init(query: Driver<String>) {
query
.throttle(0.5)
.distinctUntilChanged()
.drive(onNext: { [weak self] (addressString) in
self?.geocode(addressString: addressString)
})
.disposed(by: disposeBag)
}
// MARK: - Public API
func location(at index: Int) -> Location? {
guard index < _locations.value.count else { return nil }
return _locations.value[index]
}
func viewModelForLocation(at index: Int) -> LocationRepresentable? {
guard let location = location(at: index) else { return nil }
return LocationsViewLocationViewModel(location: location.location, locationAsString: location.name)
}
// MARK: - Helper Methods
private func geocode(addressString: String?) {
guard let addressString = addressString, !addressString.isEmpty else {
_locations.value = []
return
}
_querying.value = true
// Geocode Address String
geocoder.geocodeAddressString(addressString) { [weak self] (placemarks, error) in
var locations: [Location] = []
self?._querying.value = false
if let error = error {
print("Unable to Forward Geocode Address (\(error))")
} else if let _placemarks = placemarks {
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)
})
}
self?._locations.value = locations
}
}
}
This may look a bit overwhelming, but stick with me. What I want you to focus on is the geocoder
property, an instance of the CLGeocoder
class. This class is defined in the Core Location framework. It's very easy to use as I explained a few months ago.
When the user enters a city name in the search bar of the view controller, a new event is emitted by the query
driver, triggering a geocoding request. The magic happens in the geocode(addressString:)
method in which we call geocodeAddressString(_:completionHandler:)
on the CLGeocoder
instance. That's what we want to avoid. If we want to write fast and reliable unit tests for the AddLocationViewViewModel
class, we need to extract the CLGeocoder
instance, and any references to the Core Location framework, from the AddLocationViewViewModel
class.
The AddLocationViewController
has a property, viewModel
, of type AddLocationViewViewModel!
, an implicitly unwrapped optional. The view model is instantiated in the viewDidLoad()
method of the AddLocationViewController
class.
class AddLocationViewController: UIViewController {
// MARK: - Properties
@IBOutlet var tableView: UITableView!
@IBOutlet var searchBar: UISearchBar!
@IBOutlet var activityIndicatorView: UIActivityIndicatorView!
...
fileprivate var viewModel: AddLocationViewViewModel!
...
// MARK: - View Life Cycle
override func viewDidLoad() {
super.viewDidLoad()
// Set Title
title = "Add Location"
// Initialize View Model
viewModel = AddLocationViewViewModel()
...
}
...
}
Plan of Attack
Step 1: Define a Protocol
The plan of attack is straightforward. The AddLocationViewViewModel
class should not need to know which object is responsible for performing the geocoding request. It only needs a reference to an object that can perform a geocoding request. The view model isn't interested in how that object gets the job done.
Does that ring a bell? It means that we need to define a protocol, LocationService
, that defines an interface for performing a geocoding request. We take advantage of protocol-oriented programming to solve the problem. That's the first piece of the puzzle.
Step 2: Adopt Protocol
We then need to create and implement a type capable of performing a geocoding request. That type needs to conform to the LocationService
protocol we defined earlier.
Step 3: Inject Dependency
Is that it? We're almost there. While we could make the view model responsible for initiating the location service, that still wouldn't allow us to mock the CLGeocoder
class and stub the responses the Core Location framework receives from Apple's location services.
The third and last piece of the puzzle is dependency injection. We need to inject the location service into the view model. Why? And how? By injecting the location service into the view model, we can replace it with a mock location service in the unit tests we write. We have several options to inject the location service. The one we use in this example is initializer injection.
What's Next
In the next installment of this series, we define the LocationService
protocol, implement a type that conforms to the protocol, and inject an instance of the type into the view model. This also means we have some refactoring to do.