Advanced Dependency Injection

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.

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:

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:

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:

  1. Inject the test double
  2. Method is invoked
  3. New object is instantiated
  4. 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 the displayLocateStore(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‘s placemark property is read-only, so we can’t assign the real placemark after instantiation.
  • So we can only instantiate MKMapItem when the real placemark 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 the placemark becomes available to create a new MKMapItem instance.
  • So, how can we inject a test double for this dependency?

A Possible Workaround

Serge then suggested a workaround:

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. Since mapItem 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)

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:

  1. Ambient Context
  2. Constructor Injection
  3. Property Injection
  4. Method Injection
  5. 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:

to the following:

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:

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:

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:

The testDisplayLocateStoreShouldOpenStoreInAppleMap() test is also much simpler:

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 as createMapItem(placemark:) to instantiate a MKMapItem instance.
  • Next, we’ll create a new subclass to inherit from ListViewController such as TestingListViewController.
  • We then override the createMapItem(placemark:) method in TestingListViewController to return an instance of MapItemSpy instead of MKMapItem.
  • In the test, we can now assert that the MapItemSpy‘s openInMaps(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 real ListViewController 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:

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:

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:

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():

The testDisplayLocateStoreShouldOpenStoreInAppleMap() test now looks like:

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 use List.LocateStore.MapItem. And Clean Swift provides a good place for you to define this in ListModels.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.

Raymond
I've been developing in iOS since the iPhone debuted, jumped on Swift when it was announced. Writing well-tested apps with a clean architecture has been my goal.

32 Comments

Leave a Comment

Your email address will not be published. Required fields are marked *