In this post a while back, I mentioned two options for setting up unit test-friendly dependency injection in an iOS app: dependency injection libraries and constructor injection with code-defined views. In that piece, I talked about using a code-only UI to achieve my testing goals.
Since then, I have learned a few more strategies that allow me to unit test my iOS apps while still defining the views through the storyboard, as Apple intended. Here I’ll share some of what that looks like with you.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
class ExampleViewController: UITableViewController { | |
@IBOutlet var someTextLabel: UITextLabel! | |
class func loadFromStoryboard() -> ExampleViewController | |
{ | |
let controller = UIStoryboard(name:"Main", bundle:NSBundle(forClass:self)) | |
.instantiateViewControllerWithIdentifier("ExampleViewController") | |
as! ExampleViewController | |
return controller | |
} | |
} |
Here you see a chopped down version of my view controller. Notice the @IBOutlet, which is connected to a label on the ExampleViewController scene of my Main.storyboard. The setup on lines 7-9 specifies the storyboard to draw from for loading, and it also specifies the exact view controller within that storyboard to instantiate. Storyboards allow us to set a subclass of view controller for each view controller in the scene so we can connect our scenes to custom behavior. The OS will call the class method loadFromStoryboard() and return the correct controller upon running the app.
In this case, as in many cases with iOS development, we don’t want to interfere with the initialization of the ViewController objects—hence the loadFromStoryboard() class method. When we need to create an instance of our view controller for testing, though, we can call loadFromStoryboard() directly, like so:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
func testNavigateToView_loadsView() { | |
let controller = ExampleViewController.loadFromStoryboard() | |
assertThat(controller.view, present()) | |
UIWindow.present(viewController: controller) { () in | |
assertThat(controller.someTextLabel.text, presentAnd(equalTo("Why hello there!"))) | |
} | |
} |
There are a couple of view-specific things going on here that you don’t run into with unit tests on a code-only UI, so I’ll go through and explain those here.
First, you see the call to the ExampleViewController class method loadFromStoryboard() to instantiate a ViewController for testing. This is the simplest case; we can add parameters to this method to dictate the dependencies of the ViewController, but that is a topic for another post.
Immediately, we assert that the controller’s view is present. We do this because iOS lazy-loads its views. We want to load that view before we make any assertions on it.
Finally, the assertion is inside a block that presents our controller for testing. This happens because iOS tests run through its simulator, and when iOS reloads the test instance of our app, our view controller under test may not be the one that’s visible. For example, if I am testing a view that the user sees upon login, when the app relaunches and the user is logged out, the login view will be visible—not my view under test. The presenter window forces the ViewController under test to be presented, so we can make view assertions with confidence that that view is visible.