The template for View Controller unit testing
Hot topics like this one, testing view controllers, often come back from time to time and get some updates.
It probably all started with Testing View Controllers by Daniel Eggert back in 2013. Now quite out-dated as in Objective-C and showing examples of mocking using OCMock[1]. Also, using mocking frameworks tells me that D.I. could have been better used in those examples.
If you are using mocking frameworks, it means that you haven’t done D.I. correctly.
— Alberto De Bortoli (@albertodebo) February 11, 2018
An article less focused on the code and more on how convincing people of the benefit of unit testing view controllers is The Powerful Hidden Benefit of Testing View Controllers by Jon Reid. The final paragraph is even titled 'How to convince your team lead' (which makes me think a little as it should definitely be the other way around). Grown-ups should be long past that point, if you still have to spend time 'convincing' your team lead that testing is a non optional thing if you need to produce stable software, well... you have a much bigger problem than testing view controllers.
Remember that the only code your tests need to cover is any code you require to work.
— Graham Lee (@iwasleeg) June 8, 2015
A more recent article I recommend reading is Clean Swift's 'Testing View Controllers' part 1 and 2.
Also good is the iterative solution outlined by NatashaTheRobot in The One Weird Trick For Testing View Controllers in Swift.
The approach described in my article is very much in-line with the content of the two articles linked above and proposes a further refinement also in light of the recently introduced Cocoapods' test specs.
This is the approach that we use in the modules we build at Just Eat.
The foundation
Enough talking, let's get to the code. Here is the helper class to be used in test suites.
import XCTest
import UIKit
class TopLevelUIUtilities<T: UIViewController> {
private var rootWindow: UIWindow!
func setupTopLevelUI(withViewController viewController: T) {
rootWindow = UIWindow(frame: UIScreen.main.bounds)
rootWindow.isHidden = false
rootWindow.rootViewController = viewController
_ = viewController.view
viewController.viewWillAppear(false)
viewController.viewDidAppear(false)
}
func tearDownTopLevelUI() {
guard let rootWindow = rootWindow as? UIWindow,
let rootViewController = rootWindow.rootViewController as? T else {
XCTFail("tearDownTopLevelUI() was called without setupTopLevelUI() being called first")
return
}
rootViewController.viewWillDisappear(false)
rootViewController.viewDidDisappear(false)
rootWindow.rootViewController = nil
rootWindow.isHidden = true
self.rootWindow = nil
}
}
The above code helps you to setup the necessary environment to load a view controller: a window. It also triggers the loading of the view controller's view and takes care of calling the life-cycle methods, de facto mimicking the presentation of the view controller on screen.
It's always good practice to test software in isolation, meaning that there very few reasons to set the host application in the test target, which should be set like so:
I'm arguing that in the examples in the article by NatashaTheRobot mentioned above, there are references to the shared application
UIApplication.sharedApplication().keyWindow!.rootViewController = ...
which clearly exists only when tests run with a host application.
Using TopLevelUIUtilities
, which creates a temporary window, we safely avoid the reference to the UIApplication
singleton.
A note on Cocoapods' test specs
If you are developing a pod, Cocoapods 1.4.0 lets you make use of test specs. You can read more about them at here and here).
Long story short, here is what you can now put in the podspec to generate a target in the Pods project for the tests, meaning that you don't have to manually add tests to the main project anymore.
s.name = 'MyLibrary'
...
s.test_spec 'Tests' do |test_spec|
test_spec.source_files = 'MyLibrary-Tests/**/*.swift'
end
You should then add the following to the demo project's Podfile:
target 'MyLibrary_Example' do
platform :ios, '10.0'
pod 'MyLibrary', :path => '../', :testspecs => ['Tests']
end
If you want to be a bad boy and depend on the hosting app, you can still set
test_spec.requires_app_host = true
but as we've seen, with the stack proposed in this article you shouldn't need access to root level objects such as the AppDelegate
.
Not relying on a host app allows to run unit tests without having to install the app on the simulator, which is also great for improving execution time.
Concrete usage
Here is a realistic use case example for one of your view controller test suites. In this case, the view controller instance is created from a storyboard (as you know, variations may apply, depending on the structure of the storyboard).
private var rootViewController: MyViewController!
private var topLevelUIUtilities: TopLevelUIUtilities<MyViewController>!
override func setUp() {
super.setUp()
let storyboard = UIStoryboard(name: "MyViewController", bundle: MyBundle)
let myViewController = storyboard.instantiateInitialViewController() as! MyViewController
myViewController.someProperty = stubProperty
rootViewController = myViewController
topLevelUIUtilities = TopLevelUIUtilities<MyViewController>()
topLevelUIUtilities.setupTopLevelUI(withViewController: rootViewController)
}
override func tearDown() {
rootViewController = nil
topLevelUIUtilities.tearDownTopLevelUI()
topLevelUIUtilities = nil
super.tearDown()
}
That's all you need for the setup of the test suite!
Obviously, you need to have your code structured appropriately with dependencies injected because... that's how good software is made 👀.
I am very much assuming the understanding and application of the dependency injection concept from the reader, allowing the surfacing of light view controllers[2].
Just for the sake of completeness, here is a very basic but decent example that you could use as a template to cover most of the logic left in the view controller. It includes a UI component (UITableView
) displaying data fetched from a service.
After all, what is a view controller if not the glue code between business logic and UI? Close your eyes and force yourself to abstract even further than the architectural design pattern you like. Think higher. MVC, MVP, MVVM, VIPER they are all the same thing. Shots fired.
func test_loadResults_success() {
let expectation = XCTestExpectation(description: #function)
let numberOfResults = 3
rootViewController.service = StubService(fetch_numberOfResultsForCompletion: numberOfResults)
XCTAssertEqual(rootViewController.tableView.numberOfRows(inSection: 0), 0)
rootViewController.triggerFetch()
// the stubService should fake the async behaviour, making a dispatch async/asyncAfter needed
DispatchQueue.main.async {
XCTAssertEqual(rootViewController.tableView.numberOfRows(inSection: 0), numberOfResults)
expectation.fulfill()
}
wait(for: [expectation], timeout: someSensibleTimout)
}
Many other examples can be found in Unit-Testing a ViewController by Pritesh Nandgaonkar should you need further help in writing good unit tests.
Happy testing!
Special thanks to Alan Nichols for reviewing 😊
I believe the humanity came to realize over the past decade that OCMock should be avoided. Pure Swift doesn't really allow reflection out-of-the-box, which is a good thing and it deserves a whole article to discuss why. ↩︎
Lighter View Controllers by Chris Eidhof from 2013 is still a valid article after all these years. Good principles never go out of fashion. I always thought that once software engineers understand the fundamentals (such as SOLID principals and generally good design), there is no way for them to create a Massive View Controller, it would simply be against nature for good developers. ↩︎