It 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 testability of CLLocationManager.  

 

Improving testability of CLLocationManager

We have developed an app that has a map and uses the location services of Apple’s CoreLocation 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 that is 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 doing a mock of the external interface of the CLLocationManager using protocols.

We start with our class of location services that we will call LocationService.
It is a class that has the 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 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 we want it to return.

To solve this we will start by creating an interface that will have the same methods as 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 of 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 the new interface that we have created.

We add an extension to the class CLLocationManager 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 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, that we already had CoreLocation, now 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 the requestLocation () method is executed.

3: We inject our mock into the creation of the LocationService. 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 was CLLocationManager.

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 are interested in knowing more about CLLocationManager or mobile app development in general, I highly recommend you to subscribe to our monthly newsletter by clicking here

 

If you found this article about CLLocationManager  interesting, you might like…

 

iOS Objective-C app: sucessful case study

iOS snapshot testing

How to simplify the data layer with MoyaRx and Codable 

Espresso Testing

Mobile app development trends of the year

Banco Falabella wearable case study 

Mobile development projects 

Viper architecture advantages for iOS apps 

Why Kotlin ? 

Software architecture meetups