The MKMapItem
class is an Apple built-in class. How do I make sure the openInMaps(launchOptions:)
method is being invoked on MKMapItem
when I test the displayRoute(viewModel:)
method?
1 2 3 4 5 6 7 8 9 10 11 12 |
class MapViewController: UIViewController { func displayRoute(viewModel: Map.ShowRoute.ViewModel) { guard let lat = viewModel.lat, let lng = viewModel.lng else { return } // ... let mapItem = MKMapItem(placemark: placemark) // ... mapItem.openInMaps(launchOptions: options) } } |
You’re passing in lat
and lng
in the viewModel
, and then use them to create a Placemark
then MKMapItem
. This means all inputs are under your control. The output is the fact that the openInMaps(launchingOptions:)
method is invoked on a MKMapItem
object. When you test the displayRoute(viewModel:)
method, you don’t care what the openInMaps(launchingOptions:)
method does. You only care that it’s being invoked.
In short, it’s only necessary to subclass MKMapItem
and override the openInMaps(launchingOptions:)
method to record its invocation. An example test double can be a simple spy 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 } } |
No surprise here with the spy definition here. But how do you use it? I got this question a lot, to my surprise. In the original displayRoute(viewModel:)
method, the mapItem
is a local variable, and that’s where many people get stuck. They don’t know how to use the MapItemSpy
to replace the real MapItem
. Some will overthink and complicate things.
Here’s the simplest solution. Promote mapItem
from a local variable to an instance variable so that you can use setter dependency injection to use your spy in your test. Yes, you end up changing your app code a little to accommodate for testing, but that’s a small price to pay for the simplicity.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
class MapViewController: UIViewController { var mapItem: MKMapItem! func displayRoute(viewModel: Map.ShowRoute.ViewModel) { guard let lat = viewModel.lat, let lng = viewModel.lng else { return } // ... mapItem = MKMapItem(placemark: placemark) // ... mapItem.openInMaps(launchOptions: options) } } |
In the testDisplayRouteShouldOpenMapAndShowRoute()
test method’s Given phase, you can create the MapItemSpy
and assign it to sut.mapItem
.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
func testDisplayRouteShouldOpenMapAndShowRoute() { // Given let mapBusinessLogicSpy = MapBusinessLogicSpy() sut.interactor = mapBusinessLogicSpy 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 Then phase, you can check mapItemSpy.openInMapsCalled
to make sure the openInMaps(launchOptions:)
method is being invoked as a result of calling displayRoute(viewModel:)
.
Thanks for the post! However, I don’t think the code in the last snippet will pass the test.
It’s true that in line 7 of the test, you inject your spy, however when you call displayRoute, your spy will simply get replaced by an MKMapItem (line 9) and openInMaps will be called on that instance, not on your spy object. You have to guard against that by checking if it’s nil or something, which might break the logic of calling displayRoute twice, so you need to be extra careful of that.
You could use factory injection, but I feel like that might be a bit overkill.
As a different idea, I’d rather use the wonderful idea from Michael Feather’s book to move the MKMapItem creation into a public method in your SUT, so line 9 would read something like: createMapItem(placeholder: placeholder).
Then you can subclass your SUT and override createMapItem(placeholder:) and you return a spy from there.
Hi Lordzsolt,
Have you checked out the follow-up post to this particular problem? It examines 5 different types of dependency injection techniques to see which one(s) can be used to solve this problem.
The solution you mentioned works, but it also changes the subject under test which is not ideal. As your app and test suite grows, it becomes easier to abuse this technique. As a result, the subject under test can substantially differ from the real thing you’re trying to test. Most of all, it’s super difficult to detect and fix without re-examining all your tests.