Improving the Testability of CLLocationManager
Learn more about how you can improve the CLLocationManager testability!
Join the DZone community and get the full member experience.
Join For FreeIt is common to find difficulties with the response of methods that are not ours when we write class tests that have external dependencies. Let’s see a typical case in mobile development where we can find this problem and find out how we can improve the testability of our CLLocationManager
.
Improving the Testability of CLLocationManager
We have developed an app with a map that uses the location services of Apple’sCoreLocation
to obtain the user’s location.
As we have said, the problem comes when our class uses the CoreLocation framework.
When calling the requestLocation()
method of the CLLocationManager
class, it tries to retrieve the user’s location and calls our delegated method in the class that the CLLocationManagerDelegate
delegate conforms to.
Our intention is to be able to control the values generated by methods, such as requestLocation ()
, and all this communication via delegates makes the process even more difficult.
Let’s see how we can do this by performing a mock of the external interface of the CLLocationManager using protocols.
We start with our class of location services, which we will call LocationService
.
This is a class that has a dependency with CLLocationManager
and, in turn, also forms its protocol (CLLocationManagerDelegate
) for communication between them.
Basically, the class has the getCurrentLocation
method that, for now, asks the framework for the user’s location and we show it in the delegate.
class LocationService: NSObject {
let locationManager: CLLocationManager
init(locationManager: CLLocationManager = CLLocationManager()) {
self.locationManager = locationManager
super.init()
self.locationManager.desiredAccuracy = kCLLocationAccuracyHundredMeters
self.locationManager.delegate = self
}
func getCurrentLocation() {
self.locationManager.requestLocation()
self.locationManager.requestWhenInUseAuthorization()
}
}
extension LocationService: CLLocationManagerDelegate {
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations:[CLLocation]) {
guard let location = locations.first else { return }
print("The location is: \(location)")
}
func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
print("Error: \(error)")
}
}
Starting from our LocationService
class, the first thing we are going to do is change how our method of getCurrentLocation
returns as the location. And for this, we are going to declare a callback that we will use within our method to return the location.
private var currentLocationCallback: ((CLLocation) -> Void)?
func getCurrentLocation(completion: @escaping (CLLocation) -> Void) {
currentLocationCallback = { (location) in
completion(location)
}
…
}
And in the delegate method, we are going to call the new callback.
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
guard let location = locations.first else { return }
self.currentLocationCallback?(location)
self.currentLocationCallback = nil
}
As we have said before, our goal is to control the location values returned by the CLLocationManager
, and we will write a test to verify it.
func test_getCurrentLocation_returnsExpectedLocation() {
let sut = LocationService()
let expectedLocation = CLLocation(latitude: 10.0, longitude: 10.0)
let completionExpectation = expectation(description: "completion expectation")
sut.getCurrentLocation { (location) in
completionExpectation.fulfill()
XCTAssertEqual(location.coordinate.latitude,expectedLocation.coordinate.latitude)
XCTAssertEqual(location.coordinate.longitude,expectedLocation.coordinate.longitude)
}
wait(for: [completionExpectation], timeout: 1)
}
The test fails because, logically, it is obtaining the actual location of the device. We still don’t have a way to inject the value it needs to return.
To solve this, we will start by creating an interface with the same methods that the CLLocationManager
class needs.
protocol LocationManagerInterface {
var locationManagerDelegate: CLLocationManagerDelegate? { get set }
var desiredAccuracy: CLLocationAccuracy { get set }
func requestLocation()
func requestWhenInUseAuthorization()
}
In our class, we must change the references of CLLocationManager
to those from our new interface LocationManagerInterface
.
var locationManager: LocationManagerInterface
init(locationManager: LocationManagerInterface = CLLocationManager()) {
self.locationManager = locationManager
super.init()
self.locationManager.desiredAccuracy = kCLLocationAccuracyHundredMeters
self.locationManager.locationManagerDelegate = self
}
The default argument of the constructor will not let us change it; it will warn us that the CLLocationManager
class does not conform to the new interface that we have created.
We add an extension to the class CLLocationManager
that is conforming our protocol to solve it.
extension CLLocationManager: LocationManagerInterface {}
We have a problem with our LocationManagerInterface
interface, and that happens because we are still coupled to the CoreLocation system delegate,CLLocationManagerDelegate
.
In order to uncouple ourselves from this dependence, we will create our own delegate.
protocol LocationManagerDelegate: class {
func locationManager(_ manager: LocationManagerInterface, didUpdateLocations locations: [CLLocation])
func locationManager(_ manager: LocationManagerInterface, didChangeAuthorization status: CLAuthorizationStatus)
}
It will have two methods very similar to those already provided by the CLLocationManager
class.
I added the method of didChangeAuthorization
as an example to show that the location can be mocked, and we can do it with the other methods available as well.
Now, we can change the CLLocationManagerDelegate
of our interface by the one we just created.
The compiler will warn us that we do not comply with the interface again.
So, we assign delegate getters and setters to return our delegate, but nevertheless, we use the CoreLocation system delegate to get the data.
extension CLLocationManager: LocationManagerInterface {
var locationManagerDelegate: LocationManagerDelegate? {
get { return delegate as! LocationManagerDelegate? }
set { delegate = newValue as! CLLocationManagerDelegate? }
}
}
Now that almost everything is connected, we must change the CLLocationManager
protocol information to our protocol.
We will do this in two steps.
First, we make our LocationService
class according to our new delegate.
extension LocationService: LocationManagerDelegate {
func locationManager(_ manager: LocationManagerInterface, didUpdateLocations locations: [CLLocation]) {
guard let location = locations.first else { return }
self.currentLocationCallback?(location)
self.currentLocationCallback = nil
...
}
And second, the thin one already hadCoreLocation;
now, it will simply call the one we created.
extension LocationService: CLLocationManagerDelegate {
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
self.locationManager(manager, didUpdateLocations: locations)
}
...
}
Going back to our test files, as we now have a class that we control, we can create a mock and overwrite the values we want to inject.
struct LocationManagerMock: LocationManagerInterface {
var locationManagerDelegate: LocationManagerDelegate?
var desiredAccuracy: CLLocationAccuracy = 0
var locationToReturn:(()->CLLocation)?
func requestLocation() {
guard let location = locationToReturn?() else { return }
locationManagerDelegate?.locationManager(self, didUpdateLocations: [location])
}
}
We make the necessary changes to our initial test to finally pass the test.
func test_getCurrentLocation_returnsExpectedLocation() {
// 1
var locationManagerMock = LocationManagerMock()
// 2
locationManagerMock.locationToReturn = {
return CLLocation(latitude: 10.0, longitude: 10.0)
}
// 3
let sut = LocationService(locationManager: locationManagerMock)
let expectedLocation = CLLocation(latitude: 10.0, longitude: 10.0)
let completionExpectation = expectation(description: "completion expectation")
sut.getCurrentLocation { (location) in
completionExpectation.fulfill()
XCTAssertEqual(location.coordinate.latitude,expectedLocation.coordinate.latitude)
XCTAssertEqual(location.coordinate.longitude,expectedLocation.coordinate.longitude)
}
wait(for: [completionExpectation], timeout: 1)
}
1: We create the instance of the new mock.
2: We pass the value that we want to return when therequestLocation()
method is executed.
3: We inject our mock into the creation of theLocationService
. Recall that in the init
of the LocationService
, we invert the dependencies making the LocationManager
according to our interface and not a specific class, which in this case wasCLLocationManager
.
In the same way, if we need to control the values of the authorization of localization permits, we can do it in the same way, adding the signature of the method that we want to overwrite to our interface.
If you found this article about CLLocationManager
interesting, you might also like:
Published at DZone with permission of Bruno Basas. See the original article here.
Opinions expressed by DZone contributors are their own.
Comments