Testing View Controller – Part 2

I’ve written a book to teach you how to write unit tests effectively. If you want to make your tests fast so you’ll actually run them and run them often to receive immediate feedback, check out Effective Unit Testing. You’ll develop the confidence that your change will not break existing features, and never have to worry about introducing regression. Write non-fragile unit tests that are assets, not liabilities. Use TDD to write testable code that drives feature development.

Last week in part 1, you wrote tests for the expiration date picker and shipping method picker. You also learned about the role of the invisible UI component in the Clean Swift VIP cycle and how it helps when writing tests for your view controllers.

But the battle against the massive view controller is only half fought.

Today, in part 2, you’ll finish testing the rest of the view controller – text fields and picker configurations. You’ll see some advanced tricks to make your test environment mimic the real app running environment.

Let’s K.O. the massive view controller.

Making text fields play nice with the keyboard

Now that you’ve tested both pickers, what about the text fields? It’ll certainly be nice to know the keyboard behaves as expected when the text fields gain/lose focus.

The text fields are part of the UI component. Interacting with them invokes the UITextFieldDelegate methods. As you’ve already seen with the UIPickerViewDataSource and UIPickerViewDelegate protocols, CreateOrderViewController’s conformance to the UITextFieldDelegate protocol makes it responsible for handling text field events.

Let’s write the tests for these delegate methods to make sure the keyboard does what you want.

First, you get references to the first two text fields in the order form. You then make the first text field the first responder. This will slide the keyboard up and make the first text field its focus.

You then invoke the textFieldShouldReturn() method and pass the first text field as an argument. This essentially simulates the user tapping the return key on the keyboard, programmatically.

Finally, you make sure the keyboard focus has moved to the second text field in your assertions.

However, when you run this test, you get a failure! The nextTextField.isFirstResponder() method returns false. Huh? You did everything correctly but yet you get a failure. What gives?

Some intricacies of testing view controller

It turns out this is one of several intricate differences between running the app and running the tests.

  • In order for a text field to become first responder, it must already be part of a view hierarchy. This means its root view’s window property must be set.
  • When you call addSubview() to update the view hierarchy, things don’t get executed immediately. Instead, the view update events are queued and executed when the run loop gets a chance to run. So you need to call NSRunLoop.currentRunLoop().runUntilDate(NSDate()) to wait for the next tick. The text field will then be added to the view hierarchy.

So you’ll need to modify the setupCreateOrderViewController() method to look like the following and add the addViewToWindow() method.

The addViewToWindow() method adds createOrderViewController’s view to the view hierarchy. Now all your textfields finally have a home!

Note that I added a window variable to contain the view hierarchy. The window variable stays in scope as long as the CreateOrderViewControllerTests are run. If I put window inside the addViewToWindow() method, window is deallocated as soon as addViewToWindow() finishes. When that happens, the CreateOrderViewController’s view hierarchy will be orphaned again.

I also allocate a new window for each test in setUp() and deallocate it in teadDown(). This is to avoid any state being carried over from one test to the next.

Now that everything is properly setup, your new test finally passes.

Let’s move on to the next test.

You first calculate the index path for the last row in the last section of the table view. You then programmatically scroll to that last row. The reason you need to do this is because the first responder status can’t be set if the text field isn’t even visible to the user.

Then you make the last text field become the first responder. Let the run loop run a bit to finish all these “Given” events.

Now you simulate the user tapping on the return key while the last text field is active.

The expectationForNotification() method creates an expectation for the UIKeyboardDidHideNotification. When the keyboard is hidden, the expectation is fulfilled. The waitForExpectationsWithTimeout() will then unblock and your assertions will get executed. You also want to make sure the last text field now loses its keyboard focus.

UPDATE

Xcode 7.1 broke the testKeyboardShouldBeDismissedWhenUserTapsReturnKeyWhenFocusIsInLastTextField() test. The UIKeyboardDidHideNotification expectation is never fulfilled. The waitForExpectationsWithTimeout() method times out and the block doesn’t execute. I don’t know why.

But I’ve found an easier way to write that test that’s not possible before. The expectation and the NSRunLoop is no longer necessary. The test is reduced to just:

When the user taps on the table view cell row, not the text field, you also want the user to be able to start editing. This is nice because the user doesn’t care what a cell and text field is. The text field doesn’t even have border to indicate a tappable area.

The following test achieves this:

This last test of the text fields is fairly straightforward. You simulate the user tapping on the first row of the table view (on the table view cell, not on the text field directly). You then make sure the text field of that last row has the keyboard focus.

This concludes all the picker and text field tests in the view controller.

What about configurePickers()?

You haven’t tested the configurePickers() method because it is private. But it is important that the pickers are properly configured and displayed to the user.

Let’s think about this for a second.

Where is configurePickers() invoked? It is invoked inside viewDidLoad(). When is viewDidLoad() invoked? It is invoked when the view is loaded by iOS.

It turns out that these view lifecycle methods such as viewDidLoad() are a special kind of implicit “IBAction” methods. They are not specified with the IBAction keyword. But they behave like IBActions.

Explicit IBAction methods are triggered by user actions, whereas implicit IBAction methods are triggered by iOS. Imagine these implicit IBAction methods are declared in the UI-facing input protocol of the view controller. That means you should also test these implicit IBAction methods.

As mentioned previously, you need to test all the methods declared in the input protocol. That means you need to test viewDidLoad(). But how?

Worry not. It is actually pretty simple and boilerplate. Believe it or not, you’ve already seen it.

You can invoke the viewDidLoad() method programmatically with the following:

Look familiar? Yes, you did this already in the setupCreateOrderViewController() method. So you don’t really need to do anything more. The viewDidLoad() method is already being called for every test case.

Although viewDidLoad() calls configurePickers(), you aren’t trying to test that fact. That fact alone doesn’t guarantee the pickers are configured properly.

Also, configurePickers() is a private method, you shouldn’t test a private method directly or set up expectation to verify a private method is invoked. viewDidLoad() calling configurePickers() is the implementation details. And implementation details is allowed to change without notice, as long as it doesn’t affect the external behavior to its user, such as the test case.

What do you do then?

Andy Matuschak asked the following question in his WWDC 2014 talk on Advanced iOS Application Architecture and Patterns:

Where is “truth”?

So let’s try to answer this question for the pickers.

What does it really mean for the pickers to be properly configured?

Is it the fact that configurePickers() is being invoked? Certainly not. The invocation doesn’t mean anything. You can’t control what configurePickers() actually does and it is allowed to change.

So where is the truth? The truth lies here. When viewDidLoad() is invoked, you want to make sure the correct input views are set up for the pickers so that:

  • When the expiration date picker becomes the first responder, it should show a date picker where the user can pick a year, month, and day.
  • When the shipping method picker becomes the first responder, it should show a picker where the user can pick one of the available shipping methods.

The following 2 lines of code inside configurePickers() achieve that:

The shippingMethodTextField and expirationDateTextField IBOutlets connect your view controller back to the UI. You can imagine they are in the view controller’s invisible UI-facing output protocol.

Here is how you write the test:

Yup, that’s right. No given. No when. They are already taken care of during the test setup. All you need to do is just to make sure the text fields’ input views are set to the right pickers.

Congratulations. You’ve just fully tested a view controller.

What have you learned so far?

You’ve learned:

And you’ve also got my Xcode templates to get you started. You can find the full source code at GitHub.

Help me decide what I should write next in the comments below.

Raymond
I've been developing in iOS since the iPhone debuted, jumped on Swift when it was announced. Writing well-tested apps with a clean architecture has been my goal.

5 Comments

  1. Seems the method described above for making sure first responder changes doesn’t work any more. There is a simple fix: after creating the UIWindow instance, simply call window.makeKeyAndVisible() and now it all works.

  2. I also think your simplified update to testKeyboardShouldBeDismissedWhenUserTapsReturnKeyWhenFocusIsInLastTextField is not necessarily correct – simply testing for the text field no longer being first responder is not enough – what if there is a misconfiguration and another text field becomes first responder, keeping the keyboard visible? The test will still pass, even though it is not the expected behaviour. I think you should still test for the keyboard did hide notification.

    1. Hi Gabriel,

      Yes, you’re right that we can certainly run more tests to verify the result is precisely what we expect. However, the ROI of going to such extent should be considered, so that you won’t have 5313930 tests for the app. I tend to be more thorough in the most important areas and less so in testing whether the keyboard shows or hides. But this post illustrates how you can do that. Thanks for your comment.

Leave a Comment

Your email address will not be published. Required fields are marked *