If we want to test the AddLocationViewViewModel
class, we need the ability to stub the responses of the geocoding requests we make to Apple’s location services. Only then can we write fast and reliable unit tests. Being in control of your environment is essential if your goal is creating a robust test suite.
Not only do we want to be in control of the response we receive from the geocoding requests, we don’t want the test suite to rely on a service we don’t control. It can make the test suite slow and unreliable.
But how do we stub the responses of the geocoding requests we make? The Core Location framework is a system framework. We cannot mock the CLGeocoder
class. The solution is simple, but it requires a bit of work.
The solution involves three steps:
First, we need to create a service that’s in charge of performing the geocoding requests. That service needs to be injected into the view model. The view model shouldn’t be in charge of instantiating the service.
Second, the service we inject into the view model conforms to a protocol we define. The protocol is nothing more than a definition of an interface that allows the view model to initiate a geocoding request. It initiates the geocoding request, it doesn’t perform the geocoding request.
Third, the service conforms to the protocol and we inject an instance of the service into the view model.
Not only does this solution decouple the view model from the Core Location framework, the view model won’t even know which service it’s using, that is, as long as the service conforms to the protocol we define.
Don’t worry if this sounds confusing. Let’s start by creating the protocol for the service. We can draw inspiration from the current implementation of the AddLocationViewViewModel
class. It shows us what the protocol should look like.
Create a new file in the Protocols group and name it LocationService.swift.
The protocol’s definition will be short. We only define the interface we need to extract the CLGeocoder
class from the view model.
LocationService.swift
1
protocol
LocationService
{
2
3
}
We first define a type alias, LocationServiceCompletionHandler
. This is primarily for convenience.
LocationService.swift
1
typealias
LocationServiceCompletionHandler
=
([
Location
],
Error
?)
->
\
2
Void
More important is the definition of the method that performs the geocoding request, geocode(addressString:completionHandler:)
. It accepts an address string and a completion handler. Because the geocoding request is performed asynchronously, we mark the completion handler as escaping. The completion handler is of type LocationServiceCompletionHandler
.
LocationService.swift
1
func
geocode
(
addressString
:
String
?,
completionHandler
:
@
escaping
Lo
\
2
cationServiceCompletionHandler
)
With the LocationService
protocol in place, it’s time to create a class that adopts the LocationService
protocol. Create a new group, Services, and create a class named Geocoder. You can name it whatever you like.
Because we’re going to use the CLGeocoder
class to perform the geocoding requests, we need to import the Core Location framework.
Geocoder.swift
1
import
CoreLocation
We define the Geocoder
class. The class should conform to the LocationService
protocol we defined earlier.
Geocoder.swift
1
import
CoreLocation
2
3
class
Geocoder
:
LocationService
{
4
5
}
The Geocoder
class has one private, lazy, variable property, geocoder
, of type CLGeocoder
.
Geocoder.swift
1
import
CoreLocation
2
3
class
Geocoder
:
LocationService
{
4
5
//
MARK:
- Properties
6
7
private
lazy
var
geocoder
=
CLGeocoder
()
8
9
}
The only thing left to do is implement the method of the LocationService
protocol. This isn’t difficult since most of the implementation can be found in the current implementation of the AddLocationViewViewModel
class.
Geocoder.swift
1
//
MARK:
- Location Service Protocol
2
3
func
geocode
(
addressString
:
String
?,
completionHandler
:
@
escaping
Lo
\
4
cationService
.
LocationServiceCompletionHandler
)
{
5
guard
let
addressString
=
addressString
else
{
6
completionHandler
([],
nil
)
7
return
8
}
9
10
// Geocode Address String
11
geocoder
.
geocodeAddressString
(
addressString
)
{
(
placemarks
,
erro
\
12
r
)
in
13
if
let
error
=
error
{
14
completionHandler
([],
error
)
15
print
(
"Unable to Forward Geocode Address (
\(
error
)
)"
)
16
17
}
else
if
let
_placemarks
=
placemarks
{
18
// Update Locations
19
let
locations
=
_placemarks
.
flatMap
({
(
placemark
)
->
Loc
\
20
ation
?
in
21
guard
let
name
=
placemark
.
name
else
{
return
nil
}
22
guard
let
location
=
placemark
.
location
else
{
retur
\
23
n
nil
}
24
return
Location
(
name
:
name
,
latitude
:
location
.
coord
\
25
inate
.
latitude
,
longitude
:
location
.
coordinate
.
longitude
)
26
})
27
28
completionHandler
(
locations
,
nil
)
29
}
30
}
31
}
This should look familiar. The only difference is that we pass the array of Location
instances to the completion handler of the method along with any errors that pop up.
We now have the ingredients we need to refactor the AddLocationViewController
and AddLocationViewViewModel
classes. Let’s start with the AddLocationViewViewModel
class.
Open AddLocationViewViewModel.swift and replace the geocoder
property with a constant property, locationService
, of type LocationService
.
AddLocationViewViewModel.swift
1
//
MARK:
-
2
3
private
let
locationService
:
LocationService
This simple change means that the AddLocationViewViewModel
class no longer knows how the application performs the geocoding requests. It could be the Core Location framework, but it might as well be some other library. This will come in handy later. It also means we can remove the import statement for the Core Location framework.
AddLocationViewViewModel.swift
1
import
RxSwift
2
import
RxCocoa
3
import
Foundation
4
5
class
AddLocationViewViewModel
{
6
7
...
8
9
}
We inject a location service into the view model using initializer injection. We pass a second argument to the initializer of the AddLocationViewViewModel
class. The only requirement for the argument is that it conforms to the LocationService
protocol. We set the locationService
property in the initializer.
AddLocationViewViewModel.swift
1
//
MARK:
- Initialization
2
3
init
(
query
:
Driver
<
String
>,
locationService
:
LocationService
)
{
4
// Set Properties
5
self
.
locationService
=
locationService
6
7
query
8
.
throttle
(
0.5
)
9
.
distinctUntilChanged
()
10
.
drive
(
onNext
:
{
[
weak
self
]
(
addressString
)
in
11
self
?.
geocode
(
addressString
:
addressString
)
12
})
13
.
disposed
(
by
:
disposeBag
)
14
}
We also need to update the geocode(addressString:)
method of the AddLocationViewViewModel
class. Because most of the heavy lifting is done by the location service, the implementation is shorter and simpler.
AddLocationViewViewModel.swift
1
private
func
geocode
(
addressString
:
String
?)
{
2
guard
let
addressString
=
addressString
,
addressString
.
character
\
3
s
.
count
>
2
else
{
4
_locations
.
value
=
[]
5
return
6
}
7
8
_querying
.
value
=
true
9
10
// Geocode Address String
11
locationService
.
geocode
(
addressString
:
addressString
)
{
[
weak
se
\
12
lf
]
(
locations
,
error
)
in
13
self
?.
_querying
.
value
=
false
14
self
?.
_locations
.
value
=
locations
15
16
if
let
error
=
error
{
17
print
(
"Unable to Forward Geocode Address (
\(
error
)
)"
)
18
}
19
}
20
}
The only change we need to make in the AddLocationViewController
class is small. Open AddLocationViewController.swift and navigate to the viewDidLoad()
method. We only need to update the line on which we initialize the view model.
AddLocationViewViewModel.swift
1
// Initialize View Model
2
viewModel
=
AddLocationViewViewModel
(
query
:
searchBar
.
rx
.
text
.
orEmpt
\
3
y
.
asDriver
(),
locationService
:
Geocoder
())
That’s it. Build and run the application to make sure we didn’t break anything. The AddLocationViewViewModel
class is now ready to be tested.