Es frecuente cuando escribimos los test de clases que tiene dependencias externas encontrar dificultades con la respuesta de métodos que no son nuestros. Vamos a ver un caso típico en el desarrollo de aplicaciones dónde nos podemos encontrar esta problemática y vas a ver como podemos mejorar la testabilidad de CLLocationManager. 

 

Mejorar la testabilidad de CLLocationManager

Hemos desarrollado una app que dispone de un mapa y usa los servicios de localización de CoreLocation de apple para obtener la ubicación del usuario.

Como hemos comentado la problemática viene cuando nuestra clase usa el framework de CoreLocation.
Al llamar al método requestLocation(), de la clase CLLocationManager, este intenta recuperar la ubicación del usuario y llama a nuestro método delegado que está en la clase que conforme el delegado de CLLocationManagerDelegate.

Nuestra intención es poder controlar los valores que generan los métodos como el de requestLocation() y toda esta comunicación vía delegados dificulta aún más el proceso.

Vamos a ver cómo podemos lograrlo haciendo un mock de la interfaz externa del CLLocationManager usando protocols.

Partimos de nuestra clase de servicios de localización que vamos a llamar LocationService.
Es una clase que tiene la dependencia con CLLocationManager y a su vez también conforma su protocolo (CLLocationManagerDelegate) para la comunicación entre estas.

Básicamente la clase dispone del método getCurrentLocation que por ahora pide al framework la ubicación del usuario y la mostramos en el delegado.


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)")
	}
}

Partiendo de nuestra clase LocationService lo primero que vamos a hacer es cambiar como devuelve la ubicación nuestro método de getCurrentLocation. Y para ello vamos a declarar un callback que usaremos dentro de nuestro método para devolver la localización.


private var currentLocationCallback: ((CLLocation) -> Void)?

	func getCurrentLocation(completion: @escaping (CLLocation) -> Void) {
    	
    	currentLocationCallback = {  (location) in
        	completion(location)
    	}
    	…
}

Y en el método del delegado vamos a llamar al callback nuevo.


    func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
    	guard let location = locations.first else { return }
    	self.currentLocationCallback?(location)
    	self.currentLocationCallback = nil
	}


Como hemos comentado antes, nuestro objetivo es controlar los valores de la localización devueltos por CLLocationManager y vamos a escribir un test para comprobarlo.


    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)
    }

El test falla porque lógicamente está obteniendo la localización real del dispositivo. Aún no tenemos modo de inyectarle el valor que queremos que devuelva.

Para solucionarlo empezaremos creando una interfaz que tendrá los mismos métodos que necesita la clase de CLLocationManager.


protocol LocationManagerInterface {
	var locationManagerDelegate: CLLocationManagerDelegate? { get set }
	var desiredAccuracy: CLLocationAccuracy { get set }
	func requestLocation()
	func requestWhenInUseAuthorization()
}

En nuestra clase debemos cambiar las referencias de CLLocationManager por las de nuestra nueva interfaz LocationManagerInterface.


    var locationManager: LocationManagerInterface
	
	init(locationManager: LocationManagerInterface = CLLocationManager()) {
    	self.locationManager = locationManager
    	super.init()
    	self.locationManager.desiredAccuracy = kCLLocationAccuracyHundredMeters
    	self.locationManager.locationManagerDelegate = self
	}

El argumento por defecto del constructor no nos dejará cambiarlo, nos avisará de que la clase de CLLocationManager no conforma la nueva interfaz que hemos creado.

Añadimos una extensión a la clase CLLocationManager conformando nuestro protocolo para solucionarlo.


extension CLLocationManager: LocationManagerInterface {}


Tenemos un problema con nuestra interfaz LocationManagerInterface, y es que aún estamos acoplados al delegado del sistema de CoreLocation, CLLocationManagerDelegate.

Para desacoplarse de esta dependencia vamos a crear nuestro propio delegado.


protocol LocationManagerDelegate: class {
	func locationManager(_ manager: LocationManagerInterface, didUpdateLocations locations: [CLLocation])
	func locationManager(_ manager: LocationManagerInterface, didChangeAuthorization status: CLAuthorizationStatus)
}


El cual tendrá dos métodos muy similares a los que ya proporciona la clase CLLocationManager. He añadido el método de didChangeAuthorization como ejemplo de que no solo la localización se puede mockear, podemos hacerlo con los otro métodos disponible también.

Ahora ya podemos cambiar el CLLocationManagerDelegate de nuestra interfaz por el que acabamos de crear. El compliador nos avisará de que no cumplimos nuevamente con la interfaz.

Así que asignamos los getters y setters del delegado para que devuelvan nuestro delegado pero sinembargo use el delegado del sistema de CoreLocation para obtener los datos.


extension CLLocationManager: LocationManagerInterface {
	var locationManagerDelegate: LocationManagerDelegate? {
    	get { return delegate as! LocationManagerDelegate? }
    	set { delegate = newValue as! CLLocationManagerDelegate? }
	}
}

Ahora que está casi todo conectado debemos hacer el cambio de información del protocolo de CLLocationManager a nuestro protocolo.

Esto lo realizaremos en dos pasos.

Primero hacemos que nuestra clase LocationService conforme nuestro nuevo delegado.


extension LocationService: LocationManagerDelegate {
	func locationManager(_ manager: LocationManagerInterface, didUpdateLocations locations: [CLLocation]) {
    	guard let location = locations.first else { return }
    	self.currentLocationCallback?(location)
    	self.currentLocationCallback = nil
    	...
	}

Y segundo el delgado que ya disponiamos de CoreLocation ahora simplemente llamará al que hemos creado.


extension LocationService: CLLocationManagerDelegate {
	
	func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
    	self.locationManager(manager, didUpdateLocations: locations)
	}
	...
}

Volviendo a nuestros archivos de test, como ahora disponemos de una clase que controlamos nosotros, podemos crear un mock y sobrescribir los valores que queremos inyectar.


    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])
        }
    }

Hacemos los cambios necesarios a nuestro test inicial para que finalmente pase el 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: Creamos la instancia del nuevo mock.

2: Le pasamos el valor que queremos que devuelva al ejecutarse el método de requestLocation().

3: Inyectamos nuestro mock en la creación del LocationService.
Recordemos que en el init del LocationService invertimos las dependencias haciendo que el locationManager conforme nuestra interfaz y no una clase concreta, que en este caso era CLLocationManager.

Del mismo modo si necesitamos controlar los valores de la autorización de permisos de localización podemos hacerlo del mismo modo, añadiendo la firma del método que queremos sobreescribir a nuestra interfaz.

 

Suscríbete a nuestro newsletter para estar al día de desarrollo de aplicaciónes moviles e testabilidad de CLLocationManager en concreto!

 

Si este artículo sobre testabilidad de CLLocationManager te gustó, te puede interesar:

 

iOS snapshot tests

F-Bound en Scala

Tendencias en aplicaciónes móviles

Patrón MVP en iOS

Debugging con Charles Proxy en Android emulator

Por qué Kotlin?   

Integración Continua en iOS usando Fastlane y Jenkins  

Cornerjob – iOS objective-C app un caso de exito