iOS: The One Weird Trick For Testing View Controllers in Swift
I’ve been testing View Controllers in Swift for the past year, but for some reason, last week all my tests would just randomly start failing due to some type of timer / race condition issues. Turns out, I was setting up my View Controller tests incorrectly:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
import UIKit import XCTest @testable import MyProject class ViewControllerTests: XCTestCase { var viewController: ViewController! override func setUp() { super.setUp() let storyboard = UIStoryboard(name: "Main", bundle: NSBundle.mainBundle()) let navigationController = storyboard.instantiateInitialViewController() as! UINavigationController viewController = navigationController.topViewController as! ViewController UIApplication.sharedApplication().keyWindow!.rootViewController = viewController // this is the line responsible for the race condition NSRunLoop.mainRunLoop().runUntilDate(NSDate()) } // tests here } |
The problem was clearly with the last line in my setup NSRunLoop.mainRunLoop().runUntilDate(NSDate())
. Since the goal here is to make sure that the UIViewController life cycle functions – including viewDidLoad
and viewDidAppear
– get called correctly, I replaced this line with viewController.loadView()
. I ran into the same frustrating issue.
So after googling around, I found an amazing presentation from the testing expert @modocache: Everything You (N)ever Wanted to Know about Testing View Controllers . Go ahead and flip though it. I’ll wait!
Instead of using NSRunLoop.mainRunLoop().runUntilDate(NSDate())
or viewController.loadView()
, the key is to use let _ = viewController.view
like this:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
override func setUp() { super.setUp() let storyboard = UIStoryboard(name: "Main", bundle: NSBundle.mainBundle()) let navigationController = storyboard.instantiateInitialViewController() as! UINavigationController viewController = navigationController.topViewController as! ViewController UIApplication.sharedApplication().keyWindow!.rootViewController = viewController // The One Weird Trick! let _ = navigationController.view let _ = viewController.view } |
I’ll be honest and be the first to admit that this is not a solution I would have naturally come up with ever. After discussing it with @allonsykraken, I kind of understand why it works.
The key here is that Apple overrides the viewController’s view getter to call the loadView
function and do a bunch of other things we have no access to. If anyone else has other great insights into why this works, feel free to add it in the comments!
I went ahead and updated my other blog posts on View Controller testing if you’re interested in learning more:
UPDATE: As Tomasz Szulc points out below in the comments, Apple has a whole explanation in the documentation about why you shouldn’t call loadView()
:
You should never call this method directly. The view controller calls this method when its view property is requested but is currently nil. This method loads or creates a view and assigns it to the view property.
If the view controller has an associated nib file, this method loads the view from the nib file. A view controller has an associated nib file if the nibName property returns a non-nil value, which occurs if the view controller was instantiated from a storyboard, if you explicitly assigned it a nib file using the initWithNibName:bundle: method, or if iOS finds a nib file in the app bundle with a name based on the view controller’s class name. If the view controller does not have an associated nib file, this method creates a plain UIView object instead.
If you use Interface Builder to create your views and initialize the view controller, you must not override this method.
You can override this method in order to create your views manually. If you choose to do so, assign the root view of your view hierarchy to the view property. The views you create should be unique instances and should not be shared with any other view controller object. Your custom implementation of this method should not call super.
If you want to perform any additional initialization of your views, do so in the viewDidLoad method.
UPDATE 2: I really like @salutis‘s suggestion on making this a lot more readable:
@NatashaTheRobot Readability tip: Make a UIViewController extension with “preloadView()” method that does just “_ = view”.
— Rudolf Adamkovič (@salutis) August 2, 2015
Here is what the code would look like:
1 2 3 4 5 6 7 |
// UIViewController+Extension extension UIViewController { func preloadView() { let _ = view } } |
1 2 3 4 5 6 7 8 9 10 11 12 |
// Your Test File let storyboard = UIStoryboard(name: "Main", bundle: NSBundle.mainBundle()) let navigationController = storyboard.instantiateInitialViewController() as! UINavigationController viewController = navigationController.topViewController as! ViewController UIApplication.sharedApplication().keyWindow!.rootViewController = viewController // Using the preloadView() extension method navigationController.preloadView() viewController.preloadView() |
UPDATE 3: (This is the reason I love blogging. Learning so much right now!) Another great Swift tip from @jckarter that I didn’t know about!
@NatashaTheRobot @salutis The let isn't necessary. You can also just assign '_ = thingToIgnore'
— Joe Groff (@jckarter) August 2, 2015
So your extension can now be even prettier like this:
1 2 3 4 5 6 7 |
// UIViewController+Extension extension UIViewController { func preloadView() { _ = view } } |
UPDATE 4: @joemasilotti has a less hacky solution to loading the view!
@NatashaTheRobot @modocache @allonsykraken plain old Obj-C: XCTAssertNotNil(controller.view);
— Joe Masilotti (@joemasilotti) August 2, 2015
1 2 3 4 5 6 7 8 9 10 11 12 |
super.setUp() let storyboard = UIStoryboard(name: "Main", bundle: NSBundle.mainBundle()) let navigationController = storyboard.instantiateInitialViewController() as! UINavigationController viewController = navigationController.topViewController as! ViewController UIApplication.sharedApplication().keyWindow!.rootViewController = viewController // Test and Load the View at the Same Time! XCTAssertNotNil(navigationController.view) XCTAssertNotNil(viewController.view) |