LooLocator: The ViewModel


In the previous posts, we’ve created the Model, Location, and Networking components, and we’ll use them to design our ViewModel. The ViewModel will need a method to get the current location, and another one to get amenities. Here’s how we can define the protocol for the ViewModel.

protocol MapViewModelConfirming {
    func getCurrentLocation() -> (Double, Double)
    func getAmenities(in range: Int, type: AmenityType, completion: @escaping CompletionBlock)
}

We’ll also need a protocol to which the observer of the ViewModel will need to confirm to. The ViewModel will send messages to this delegate on completion of the tasks. In our final implementation this will be the ViewController.

protocol MapViewModelObservable {
    // This needs to be supplied by the VM Observer
    associatedtype Amenity
    
    func setCurrentLocation(latitude: Double, longitude: Double)
    func addAmenityToMap(amenity:Amenity)
}

The ViewModel has two dependencies, a LocationProvidable instance, and an AmenityRequest instance. The ViewModel will also need to advertise that it needs a parameter of type MapViewModelObservable. With these details in hand, here’s how the MapViewModel could be implemented.

class MapViewModel<S:MapViewModelObservable>: MapViewModelConfirming, LocationObservable {

    var locationProvider: LocationProvidable
    var amenityRequest: AmenityRequest
    var listenerView: S
    
    // inject the dependencies in ctor
    init(locationProvider: LocationProvidable, 
         amenityRequest: AmenityRequest, 
         listener: S) {

        self.locationProvider = locationProvider
        self.locationProvider.startLocationUpdates()
        self.amenityRequest = amenityRequest
        self.listenerView = listener
        
        defer {
            self.locationProvider.setListener(listener: self)
        }
    }
    
    func getCurrentLocation() -> (Double, Double) {
        let (lat, lon) = locationProvider.getCurrentLocation()
        return (lat, lon)
    }
    
    func getAmenities(in range: Int, type: AmenityType, 
                      completion: @escaping CompletionBlock) {
        
        let (lat, lon) = getCurrentLocation()
        print("latitude: \(lat) longitude: \(lon)")
        if lat == 0 && lon == 0 {
            completion(false, nil)
        }
        amenityRequest.getAmeneties(of: AmenityType.Toilets,
                                    latitude: lat,
                                    longitude: lon,
                                    radius: Double(range)) { (success, locations) in
                                        completion(success, locations)
        }
    }
    
    // This is a message from the location provider
    func setCurrentLocation(latitude: Double, longitude: Double) {
        listenerView.setCurrentLocation(latitude: latitude, longitude: longitude)
    }
}

Unit Tests

All the dependencies of the ViewModel are mockable and can be injected through the init() method. Let’s see how we can mock the dependencies.

MockAmenityRequest

class MockAmenityRequest: AmenityRequest {
    override func getAmeneties(of type: AmenityType,
                               latitude: Double,
                               longitude: Double,
                               radius: Double,
                               completionBlock: @escaping CompletionBlock) {

        let dummyOSMData = [Location(id: "DummyLocation001", 
                            title: "Dummy Amenity", 
                            locationDescription: "Dummy Amenity Name",
                            coordintes: (52.51631, 13.37777), 
                            isAccessible: false)]

        completionBlock(true, dummyOSMData as AnyObject)
    }
}

MockLocationProvider

class MockLocationProvider: LocationProvidable {
    var listener: LocationObservable?
    func setListener(listener: LocationObservable) {
        self.listener = listener
    }

    func startLocationUpdates() {
        listener?.setCurrentLocation(latitude: 52.51631, longitude: 13.37777)
    }
    
    func getCurrentLocation() -> (Double, Double) {
        return (52.51631, 13.37777)
    }
}

MockViewController

class MockViewController: MapViewModelObservable {
    func setCurrentLocation(latitude: Double, longitude: Double) {}
    func addAmenityToMap(amenity: Location) {}
    typealias Amenity = Location
}

The unit tests check if the getCurrentLocation() and getAmenities(...) method work as advertised.

class MapViewModelTests: QuickSpec {
    override func spec() {
        describe("Given a MapViewModel") {
            var viewModel: MapViewModel<MockViewController>?
            beforeEach {
                let mockLocationProvider = MockLocationProvider()
                let mockAmenityRequest = MockAmenityRequest()
                let mockViewController = MockViewController()
                viewModel = MapViewModel(locationProvider: mockLocationProvider,
                                         amenityRequest:mockAmenityRequest,
                                         listener:mockViewController)
            }
            
            it("get current location", closure: {
                if let (lat, lon) = viewModel?.getCurrentLocation() {
                    expect(lat).to(equal(52.51631))
                    expect(lon).to(equal(13.37777))
                }
            })
            it("should get all amenities in range", closure: {
                var successFlag = false
                var locations: [Location] = []
                viewModel?.getAmenities(in: 1000, type: AmenityType.Toilets, completion: {
                    (success, results) in
                    successFlag = success
                    if successFlag, let results = results as? [Location] {
                        locations = results
                    }
                })
                //Assert
                expect(successFlag).toEventuallyNot(beFalse())
                expect(locations).toEventuallyNot(beEmpty())
                
                expect(locations.first?.id).toEventuallyNot(beEmpty())
                expect(locations.first?.id).toEventually(equal("DummyLocation001"))
                expect(locations.first?.title).toEventually(equal("Dummy Amenity"))
                
                expect(locations.first?.coordinates.0).toEventuallyNot(equal(0))
                expect(locations.first?.coordinates.1).toEventuallyNot(equal(0))
            })
        }
    }
}

Running the tests should result in a success, with the following output:

Test Suite 'MapViewModelTests' started at 2018-03-06 14:55:40.809
Test Case '-[LooLocatorTests.MapViewModelTests Given_a_MapViewModel__get_current_location]' started.
Test Case '-[LooLocatorTests.MapViewModelTests Given_a_MapViewModel__get_current_location]' passed (0.003 seconds).
Test Case '-[LooLocatorTests.MapViewModelTests Given_a_MapViewModel__should_get_all_amenities_in_range]' started.
latitude: 52.51631 longitude: 13.37777
Test Case '-[LooLocatorTests.MapViewModelTests Given_a_MapViewModel__should_get_all_amenities_in_range]' passed (0.004 seconds).
Test Suite 'MapViewModelTests' passed at 2018-03-06 14:55:40.820.