Test-Driven iOS: Enabling Unit Testing with a Code-Only UI

Reading Time: 4 minutes

I write automated unit tests for each of the classes inside my apps. This includes iOS apps, which have classes that extend classes defined by the iOS framework. I want these classes to take care of the framework-specific and device-specific details of showing new screens, handling their life cycles, et cetera. For other logic that I write, I like to have my view controllers and other iOS objects delegate that work to injected classes.

So, for the unit tests on my ViewControllers, I want to mock these injected classes. I have two options for this. The first option is to use a dependency injection framework like Swinject, which allows me to specifically inject mock classes in my test targets. But if I don’t want to bring in a library, I can do this out of the box by using initializer injection. I prefer this method because it contains less magic and is therefore more self-documenting than Swinject or similar. Here’s the problem: apps with storyboards take care of newing up my ViewControllers for me, which prevents me from using constructors with arguments to new them up and pass them their dependencies.

So far, to regain control of the instantiation of my view controllers, I have removed storyboards from unit-tested apps in favor of defining my views purely in code.

This choice has tradeoffs, and it may not be the right choice for every app. This blog post is not about whether or not to choose a code-only UI over storyboards. Instead, this post is about how to transition an app from storyboards to a code-only UI.

Step 1: Remove Main Storyboard Reference in Project Settings

This is the least intuitive step, and the error message you get if you don’t do it is not very helpful. 

Click on your project in the Project View:

Project View: Click on This
Project View: Click on This

This will open your project settings. There you will see “Main” (or whatever you named your top-level storyboard” listed in the “Main Interface” field. Clear out that field:

Project Settings
Project Settings

Step 2: Create your UIWindow in the App Delegate, and add the view controller that you want to load upon launching the app. 

Inside of your AppDelegate, you have a method called application with two arguments: application and launchOptions. This is the method that is called when you start your app, similar to the main() method in the Application.java class of an Android app. 

In this method, comment out the code that sets your launch view controller automatically. Instead, you will new up a ViewController and stick it into your UIWindow yourself:


func application(application: UIApplication, didFinishLaunchingWithOptions launchOptions: [NSObject: AnyObject]?) -> Bool {
// Override point for customization after application launch.
//let splitViewController = self.window!.rootViewController as! UISplitViewController
//splitViewController.delegate = self
let window = UIWindow(frame: UIScreen.mainScreen().bounds)
self.window = window
window.rootViewController = MasterViewController()
window.makeKeyAndVisible()
return true
}

This is where you can add arguments to your ViewController’s initializer. Then, in your unit test, you can initialize your ViewController with mock versions of these dependencies and test the ViewController in isolation.

Step 3: Define your view in your ViewController

Now you need to define all the specifications for your view inside of your ViewController in the viewDidLoad() method. Here is a simple example:


class MasterViewController: UIViewController {
var activityIndicatorView: UIActivityIndicatorView = UIActivityIndicatorView()
var baseView: UIView = UIView()
override func viewDidLoad() {
super.viewDidLoad()
baseView.frame = CGRect.zero;
baseView.translatesAutoresizingMaskIntoConstraints = false
let viewsDictionary = ["view": baseView]
view.addConstraints(NSLayoutConstraint.constraintsWithVisualFormat(
"H:|-0-[view]-0-|", options: [], metrics: nil, views: viewsDictionary))
view.addConstraints(NSLayoutConstraint.constraintsWithVisualFormat(
"V:|-0-[view]-0-|", options: [], metrics: nil, views: viewsDictionary))
self.view.addSubview(tableView)
}
}

You notice some code there involving strings. This code pins the edges of my base view to the edges of the device window, regardless of the device window’s size and orientation.

If you don’t like putting this code in  viewDidLoad(), you can extract view setup to a helper method or stick it in the initializer for a custom view class. In this case I leave it in the ViewController to show you an example with the minimum number of moving parts.

Step 4: Delete your storyboard files and run your app

It should work as before! A caveat: this blog post does not cover the details of how to define your UI in code. Those details are neither trivial nor obvious, and there are not a lot of resources online about how to do it.  I found this article on VFL to be a helpful starting point, and I will be back later with more resources on this topic.

 

One comment

Leave a Reply to Test-Driven iOS: Mocking Dependencies – Chelsea TroyCancel reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.