In my last post, I showed you how to use setter dependency injection to mock an Apple built-in class by:
- Creating a super simple test double by hand
- Extracting the dependency as an instance variable
- Inject the test double in the Given phase
Two astute readers wrote in about a problem in this example. I’ll describe the problem, a workaround, and two solutions in this post. I’ve also created a sample project called WhereIsMyApple on GitHub. This app has one scene named List
that shows some Apple retail stores in a table view. When the user taps on a row, the app displays the selected retail store in Apple Map.
Quick Recap
Here is the MapViewController
in my last post again. I’m showing only the relevant code here.
1 2 3 4 5 6 7 8 9 10 11 12 13 |
class MapViewController: UIViewController { var mapItem: MKMapItem! func displayLocateStore(viewModel: List.LocateStore.ViewModel) { let placemark = MKPlacemark(coordinate: viewModel.coordinate, addressDictionary: viewModel.addressDictionary) mapItem = MKMapItem(placemark: placemark) // This will overwrite our test double ... mapItem.name = viewModel.name mapItem.openInMaps(launchOptions: nil) } } |
The displayLocateStore(viewModel:)
method extracts the coordinate and address data from the viewModel
argument. It then creates a MKPlacemark
object that is used to instantiate a new MKMapItem
instance. Next, it sets the name
property, and invokes the openInMaps(launchOptions:)
method. The effect is to display the store in the Apple map.
The simplest test double that we can create is as follows:
1 2 3 4 5 6 7 8 9 10 11 |
class MapItemSpy: MKMapItem { var openInMapsCalled = false override func openInMaps(launchOptions: [String : Any]? = nil) -> Bool { openInMapsCalled = true return true } } |
The MapItemSpy
class inherits from MKMapItem
, and overrides the openInMaps(launchOptions:)
method. It sets openInMapsCalled
to true when the method is called.
The testDisplayLocateStoreShouldOpenStoreInAppleMap()
test looks like this:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
func testDisplayLocateStoreShouldOpenStoreInAppleMap() { // Given let mapItemSpy = MapItemSpy() sut.mapItem = mapItemSpy loadView() // When let viewModel = Map.ShowRoute.ViewModel(lat: 0.0, lng: 0.0, name: "Road to Rome") sut.displayRoute(viewModel: viewModel) // Then XCTAssertTrue(mapItemSpy.openInMapsCalled, "displayRoute(viewModel:) should open the Apple Maps app and show the route to Rome") } |
In the Given phase, it creates the MapItemSpy
and assigns it to sut.mapItem
. After making the call to displayRoute(viewModel:)
in the When phase, it verifies mapItemSpy.openInMapsCalled
is true in the Then phase.
Our goal for the testDisplayLocateStoreShouldOpenStoreInAppleMap()
test is to make sure MKMapItem
‘s openInMaps(launchOptions:)
method is invoked as a result of calling ListViewController
‘s displayLocateStore(viewModel:)
method. The MapItemSpy
test double we created earlier helps us peek into the openInMaps(launchOptions:)
method, and record the invocation. This is why we want to replace the real MKMapItem
with MapItemSpy
. We don’t really want to call the real openInMaps(launchOptions:)
method because we can’t modify it to record the invocation. Instead, we want to isolate this dependency by substituting MapItemSpy
which we do have control in the openInMaps(launchOptions:)
method’s implementation to record the invocation.
A Small Problem
Victor mentioned that the test double, MapItemSpy
, is overwritten by a new local instance when the method is called. Essentially, the order of things is as follows:
- Inject the test double
- Method is invoked
- New object is instantiated
- The test double is replaced by the new instance – Oops!
As a result, the real openInMaps(launchOptions:)
method is called instead of the fake one of the test double. So the assertion fails.
The point of that post was to illustrate the simplicity of setter dependency injection. If we promote mapItem
from the method scope to the class scope (as an instance variable). We could create the MKMapItem
instance as a default value. When the displayLocateStore(viewModel:)
method is called, we could simply assign the placemark
to the mapItem
‘s placemark
property. Setter dependency injection is the simplest solution and it would work perfectly. Except when it doesn’t…
MKMapItem
‘s placemark
property is a read-only property, and we can’t, and don’t want, to modify an Apple class. So, we’re forced to create a new instance inside the displayLocateStore(viewModel:)
method. Bummer!
To summarize,
MKMapItem
is instantiated locally inside thedisplayLocateStore(viewModel:)
method.- There is no entry point to inject a test double for
MKMapItem
. - First thought is to extract it to an instance variable so that we can use property dependency injection.
- But
MKMapItem
‘splacemark
property is read-only, so we can’t assign the realplacemark
after instantiation. - So we can only instantiate
MKMapItem
when the realplacemark
becomes available. - Any dependency injection that is done before the
displayLocateStore(viewModel:)
method is invoked will not work. It’s going to be overwritten anyway when theplacemark
becomes available to create a newMKMapItem
instance. - So, how can we inject a test double for this dependency?
A Possible Workaround
Serge then suggested a workaround:
1 2 3 4 |
if mapItem == nil { mapItem = MKMapItem(placemark: placemark) } |
A new MKMapItem
instance is created only if mapItem
is nil. What does this mean for our test? After we inject MapItemSpy
, mapItem
isn’t nil anymore. So, in the displayLocateStore(viewModel:)
method, MKMapItem
is not instantiated and not assigned to mapItem
. The MapItemSpy
is not overwritten.
This will work. However, this has two unwanted side effects:
- When running the app (not the tests), after the user selects a store,
mapItem
is populated, and the store is shown in the map. However, if the user selects another store, it won’t show this newly selected store in the map. SincemapItem
is non-nil anymore, it’ll still show the first selected store in the map. - To mitigate this issue, we have to make sure to reset
mapItem
to nil. We can do this in two places:- Right after the call to
mapItem.openInMaps(launchOptions: nil)
in the same method - Right before we fire the request to the interactor –
interactor?.locateStore(request: request)
- Right after the call to
As long as you and other developers on the team remember to do this whenever mapItem
is used, it is a fine workaround. However, mapItem
can be later used for other use cases. New developers not familiar with the code base do not know about this hidden secret. Essentially, this introduces a hidden state machine that is undocumented. It is a code smell.
5 Types of Dependency Injection
Let’s dive deeper into the topic of dependency injection. Jon Reid wrote an excellent post on objc.io that describes the 5 types of dependency injection:
- Ambient Context
- Constructor Injection
- Property Injection
- Method Injection
- Extract and Override Call
I encourage you to read that post first. We’ll explore each type of dependency injections to see whether and how they can solve our problem – a local instance overwrites our test double.
1. Ambient Context
Our problem is that we’re overwriting our test double when the sut
‘s displayLocateStore(viewModel:)
method is called. It’s a local/instance variable. It doesn’t have anything to deal with singleton or any global state. So ambient context dependency injection does not really apply.
Does ambient context solve our problem? Not applicable.
2. Constructor Injection
In the original displayLocateStore(viewModel:)
method, a new MKMapItem
is instantiated, consumed, and deallocated locally. All of these happen inside the displayLocateStore(viewModel:)
method itself. The mapItem
is a local variable. We don’t have an instance variable to back it.
If we’re going to use constructor injection, we can simply promote mapItem
to an instance variable. However, we’re still forced to instantiate MKMapItem
locally because placemark
is read-only. The mapItem
variable, local or instance, will be overwritten when the displayLocateStore(viewModel:)
method is called in the When phase. Therefore, whatever we inject in the Given phase will be overwritten. This ain’t gonna work.
We can take a step further by defining a mapItemType
variable that is used to create the mapItem
instance (i.e. var mapItemType: MKMapItem.Type!
). The app code can use MKMapItem
while the test code can substitute MapItemSpy
for it. But this is changing the app code too much for our testing need. We also don’t really want to define a new init(MKMapItem.Type)
initializer for ListViewController
.
Does constructor injection solve our problem? No.
3. Property Injection
This is the setter dependency injection I mentioned in the last post. I like the term setter because it’s more explicit. We’re manually setting the property by calling the setter method. This is opposed to constructor injection where we simply use the property as a backing instance variable, which we only allow it to be set during initialization (or object construction).
But we have the same problem as in constructor injection. MKMapItem
‘s placemark
property is read-only, so we’re once again forced to instantiate it inside the displayLocateStore(viewModel:)
method. The test double we assign to the mapItem
instance variable of the sut
in the Given phase will be overwritten when the displayLocateStore(viewModel:)
method is called in the When phase.
Does property injection solve our problem? No.
4. Method Injection
Instead of creating the MKMapItem
object locally in the displayLocateStore(viewModel:)
method, can we pass it in as a function argument? The answer is yes. We can pass in our test double when we make the call to the sut
‘s displayLocateStore(viewModel:)
method. So we can use method injection to solve our problem. Let’s look at this solution in more details.
We can change the method signature to displayLocateStore(viewModel:mapItem:)
. But if we’re already passing in an MKMapItem
object, we don’t need the view model anymore. But this is not so clean. We can do better. We can keep the method signature the same such that we can still hide and encapsulate the view model details. Let’s change the List.LocateStore.ViewModel
from:
1 2 3 4 5 6 7 8 9 10 |
enum LocateStore { struct ViewModel { var name: String var coordinate: CLLocationCoordinate2D var addressDictionary: [String : Any]? } } |
to the following:
1 2 3 4 5 6 7 8 |
enum LocateStore { struct ViewModel { var mapItem: MKMapItem } } |
This solution also requires us to rethink our use case. We need to create MKMapItem
in the presenter, embed it inside the List.LocateStore.ViewModel
struct, and pass it to the view controller. We’ll need to update the presenter to convert name
, coordinate
, and addressDictionary
into MKMapItem
as follows:
1 2 3 4 5 6 7 8 9 10 |
func presentLocateStore(response: List.LocateStore.Response) { let placemark = MKPlacemark(coordinate: response.coordinate, addressDictionary: response.addressDictionary) let mapItem = List.LocateStore.MapItem(placemark: placemark) mapItem.name = response.name let viewModel = List.LocateStore.ViewModel(mapItem: mapItem) viewController?.displayLocateStore(viewModel: viewModel) } |
Since we’re making changes to how the presenter works, we need to update the tests for our presenter to verify we’re converting to MKMapItem correctly:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
func testPresentLocateStoreShouldAskViewControllerToLocateStoreInMap() { // Given let spy = ListDisplayLogicSpy() sut.viewController = spy // When let name = "Tysons Corner" let coordinate = CLLocationCoordinate2D(latitude: 38.917623, longitude: -77.222237) let response = List.LocateStore.Response(name: name, coordinate: coordinate, addressDictionary: nil) sut.presentLocateStore(response: response) // Then XCTAssertTrue(spy.displayLocateStoreCalled, "presentLocateStore(response:) should ask the view controller to locate store in map") let actualName = spy.displayLocateStoreViewModel?.mapItem.name XCTAssertEqual(actualName, name, "presentLocateStore(response:) should set the correct name") let actualCoordinate = spy.displayLocateStoreViewModel?.mapItem.placemark.coordinate XCTAssertEqual(actualCoordinate?.latitude, coordinate.latitude, "presentLocateStore(response:) should set the correct latitude") XCTAssertEqual(actualCoordinate?.longitude, coordinate.longitude, "presentLocateStore(response:) should set the correct longitude") } |
Now back to the view controller. Our displayLocateStore(viewModel:)
is much simpler now because we’ve moved the MKPlacemark
and MKMapItem
creations to the presenter. Now we just need to invoke the openInMaps(launchOptions:)
method:
1 2 3 4 5 |
func displayLocateStore(viewModel: List.LocateStore.ViewModel) { viewModel.mapItem.openInMaps(launchOptions: nil) } |
The testDisplayLocateStoreShouldOpenStoreInAppleMap()
test is also much simpler:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
func testDisplayLocateStoreShouldOpenStoreInAppleMap() { // Given let placemark = Seeds.placemark let mapItemSpy = MapItemSpy(placemark: placemark) // When let viewModel = List.LocateStore.ViewModel(mapItem: mapItemSpy) sut.displayLocateStore(viewModel: viewModel) // Then XCTAssertTrue(mapItemSpy.openInMapsCalled, "displayLocateStore(viewModel:) should ask the map view to open the store in Apple Maps") } |
In the Given phase, we create the MapItemSpy
, and pass it in as a function argument when we call the displayLocateStore(viewModel:)
method in the When phase. This is how method injection works. We don’t inject the test double in the Given phase. Instead, dependency injection happens in the When phase. The test double is injected as a method argument when the test subject is invoked.
Lastly, in the Then phase, we only need to make sure the openInMaps(launchOptions:)
method is invoked as a result.
5. Extract and Override Call
Here is the gist of this dependency injection technique:
- Instead of calling the initializer directly in place, we move this initialization to a new local method in
ListViewController
such ascreateMapItem(placemark:)
to instantiate aMKMapItem
instance. - Next, we’ll create a new subclass to inherit from
ListViewController
such asTestingListViewController
. - We then override the
createMapItem(placemark:)
method inTestingListViewController
to return an instance ofMapItemSpy
instead ofMKMapItem
. - In the test, we can now assert that the
MapItemSpy
‘sopenInMaps(launchOptions:)
method is invoked.
This technique is usually considered as a last resort, because instead of testing the real ListViewController
, we’re testing a subclass – TestingListViewController
. In this case, it’s fine as TestingListViewController
is simple and doesn’t have a lot of extra functionalities. However, as the number of your test cases grow, it’s very tempting to keep adding to TestingListViewController
to accommodate your testing needs. You’re going down a rabbit hole.
A thought comes to mind. Instead of subclassing, can we override the createMapItem(placemark:)
method in a ListViewController
extension that we define only in the test target? I tried but it didn’t work. This is what Apple says about extensions in The Swift Programming Language Guide:
Extensions can add new functionality to a type, but they cannot override existing functionality.
With a little creativity, however, we can make this work. And I promise the solution will be even simpler and cleaner if you read on. 🙂
The problems with extract and override call are:
- We don’t want to introduce a new
TestingListViewController
subclass. We want to stick to the realListViewController
as our test subject. - We can’t avoid subclassing as Swift extension cannot override the
createMapItem(placemark:)
method.
It all comes down to this. We have to use subclassing because extension doesn’t work. But we don’t necessarily have to subclass from ListViewController
. What if we create a new class to create the MKMapItem
and subclass from this new class?
In the ListModels.swift
file, define a new List.LocateStore.MapItem
class that inherits from MKMapItem
. Make this new class nested inside the List
scene’s LocateStore
use case:
1 2 3 4 5 |
enum LocateStore { class MapItem: MKMapItem {} } |
This new class is just used as a’pass-through’ to create a new MKMapItem
, so that we can override it in our tests. So leave the MapItem
class implementation empty, as we aren’t adding any new functionalities.
Next, back to the ListViewController
, make a small change in the displayLocateStore(viewModel:)
method:
1 2 3 4 5 6 7 8 |
func displayLocateStore(viewModel: List.LocateStore.ViewModel) { let placemark = MKPlacemark(coordinate: viewModel.coordinate, addressDictionary: viewModel.addressDictionary) let mapItem = List.LocateStore.MapItem(placemark: placemark) mapItem.name = viewModel.name mapItem.openInMaps(launchOptions: nil) } |
Instead of instantiating MKMapItem
using MKMapItem(placemark: placemark)
, we use List.LocateStore.MapItem(placemark: placemark)
. That’s our new empty subclass we just created.
In ListViewControllerTests.swift
, define an extension for List.LocateStore.MapItem
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
extension List.LocateStore.MapItem { struct Spy { static var openInMapsCalled = false } override open func openInMaps(launchOptions: [String : Any]? = nil) -> Bool { Spy.openInMapsCalled = true return true } } |
We override the openInMaps(launchOptions:)
method to record the invocation by setting Spy.openInMapsCalled
to true. Since we can’t have stored properties in an extension, we’ll create the Spy
struct with a static variable. this Spy
struct is simply a convenience place for our secret agent to leave a paper trail.
(Yes, I know extension isn’t supposed to be able to override existing functionality as stated by Apple. But it does work beautifully here. If anybody knows more about extensions, please leave a comment below. We’re all curious.)
With openInMapsCalled
being a static variable, we want to make sure its state doesn’t get carried over to the next test. So we’ll reset it to false in tearDown()
:
1 2 3 4 5 6 7 |
override func tearDown() { List.LocateStore.MapItem.Spy.openInMapsCalled = false window = nil super.tearDown() } |
The testDisplayLocateStoreShouldOpenStoreInAppleMap()
test now looks like:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
func testDisplayLocateStoreShouldOpenStoreInAppleMap() { // Given // When let name = "Tysons Corner" let coordinate = CLLocationCoordinate2D(latitude: 38.917623, longitude: -77.222237) let viewModel = List.LocateStore.ViewModel(name: name, coordinate: coordinate, addressDictionary: nil) sut.displayLocateStore(viewModel: viewModel) // Then XCTAssertTrue(List.LocateStore.MapItem.Spy.openInMapsCalled, "displayLocateStore(viewModel:) should ask the map view to open the store in Apple Maps") } |
Nothing in the Given phase! And you just verify List.LocateStore.MapItem.Spy.openInMapsCalled
is true in the Then phase. Simple is beautiful.
Again, you can see the whole fully working project – both the app and test code, in this GitHub repository.
There are a few things that are so nice about this extract and override call solution:
- We don’t need to subclass
ListViewController
, so we’re guaranteed to always be testing the real thing. - In the app code, instead of
MKMapItem
, we just need to useList.LocateStore.MapItem
. And Clean Swift provides a good place for you to define this inListModels.swift
. You aren’t adding any code to the view controller. - In the test code, you don’t even need to set up a test double in the Given phase. The
openInMaps(launchOptions:)
method is overridden in an extension with a built-in spy. You can simply assert it in the Then phase. - Unlike method injection, you don’t need to move a piece of your logic from the view controller to the presenter. You don’t need to update your other tests. This may or may not make sense in some situations.
If you want to learn more about unit testing and dependency injection, you can see how to apply these techniques in a real project in my new book Effective Unit Testing.
32 Comments