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.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
func testCursorFocusShouldMoveToNextTextFieldWhenUserTapsReturnKey() { // Given let currentTextField = createOrderViewController.textFields[0] let nextTextField = createOrderViewController.textFields[1] currentTextField.becomeFirstResponder() // When createOrderViewController.textFieldShouldReturn(currentTextField) // Then XCTAssert(!currentTextField.isFirstResponder(), "Current text field should lose keyboard focus") XCTAssert(nextTextField.isFirstResponder(), "Next text field should gain keyboard focus") } |
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 callNSRunLoop.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.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
var window: UIWindow! override func setUp() { super.setUp() window = UIWindow() setupCreateOrderViewController() } override func tearDown() { window = nil super.tearDown() } func setupCreateOrderViewController() { let bundle = NSBundle(forClass: self.dynamicType) let storyboard = UIStoryboard(name: "Main", bundle: bundle) createOrderViewController = storyboard.instantiateViewControllerWithIdentifier("CreateOrderViewController") as! CreateOrderViewController _ = createOrderViewController.view addViewToWindow() } func addViewToWindow() { window.addSubview(createOrderViewController.view) NSRunLoop.currentRunLoop().runUntilDate(NSDate()) } |
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.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
func testKeyboardShouldBeDismissedWhenUserTapsReturnKeyWhenFocusIsInLastTextField() { // Given // Scroll to the bottom of table view so the last text field is visible and its gesture recognizer is set up let lastSectionIndex = createOrderViewController.tableView.numberOfSections - 1 let lastRowIndex = createOrderViewController.tableView.numberOfRowsInSection(lastSectionIndex) - 1 createOrderViewController.tableView.scrollToRowAtIndexPath(NSIndexPath(forRow: lastRowIndex, inSection: lastSectionIndex), atScrollPosition: .Bottom, animated: false) // Show keyboard for the last text field let numTextFields = createOrderViewController.textFields.count let lastTextField = createOrderViewController.textFields[numTextFields - 1] lastTextField.becomeFirstResponder() NSRunLoop.currentRunLoop().runUntilDate(NSDate()) // When createOrderViewController.textFieldShouldReturn(lastTextField) expectationForNotification(UIKeyboardDidHideNotification, object: nil, handler: nil) // Then waitForExpectationsWithTimeout(1.0) { (error: NSError?) -> Void in XCTAssert(!lastTextField.isFirstResponder(), "Last text field should lose keyboard focus") } } |
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. TheUIKeyboardDidHideNotification
expectation is never fulfilled. ThewaitForExpectationsWithTimeout()
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:
123456789101112131415161718192021 func testKeyboardShouldBeDismissedWhenUserTapsReturnKeyWhenFocusIsInLastTextField(){// Given// Scroll to the bottom of table view so the last text field is visible and its gesture recognizer is set uplet lastSectionIndex = createOrderViewController.tableView.numberOfSections - 1let lastRowIndex = createOrderViewController.tableView.numberOfRowsInSection(lastSectionIndex) - 1createOrderViewController.tableView.scrollToRowAtIndexPath(NSIndexPath(forRow: lastRowIndex, inSection: lastSectionIndex), atScrollPosition: .Bottom, animated: false)// Show keyboard for the last text fieldlet numTextFields = createOrderViewController.textFields.countlet lastTextField = createOrderViewController.textFields[numTextFields - 1]lastTextField.becomeFirstResponder()// WhencreateOrderViewController.textFieldShouldReturn(lastTextField)// ThenXCTAssert(!lastTextField.isFirstResponder(), "Last text field should lose keyboard focus")}
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:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
func testTextFieldShouldHaveFocusWhenUserTapsOnTableViewRow() { // Given // When let indexPath = NSIndexPath(forRow: 0, inSection: 0) createOrderViewController.tableView(createOrderViewController.tableView, didSelectRowAtIndexPath: indexPath) // Then let textField = createOrderViewController.textFields[0] XCTAssert(textField.isFirstResponder(), "The text field should have keyboard focus when user taps on the corresponding table view row") } |
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:
1 2 3 4 5 |
let bundle = NSBundle(forClass: self.dynamicType) let storyboard = UIStoryboard(name: "Main", bundle: bundle) let createOrderViewController = storyboard.instantiateViewControllerWithIdentifier("CreateOrderViewController") as! CreateOrderViewController _ = createOrderViewController.view |
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:
1 2 3 |
shippingMethodTextField.inputView = shippingMethodPicker expirationDateTextField.inputView = expirationDatePicker |
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:
1 2 3 4 5 6 7 8 9 10 11 |
func testCreateOrderViewControllerShouldConfigurePickersWhenViewIsLoaded() { // Given // When // Then XCTAssertEqual(createOrderViewController.expirationDateTextField.inputView, createOrderViewController.expirationDatePicker, "Expiration date text field should have the expiration date picker as input view") XCTAssertEqual(createOrderViewController.shippingMethodTextField.inputView, createOrderViewController.shippingMethodPicker, "Shipping method text field should have the shipping method picker as input view") } |
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:
- How to use the Clean Swift architecture to write better code
- The different kinds of test doubles
- What methods to test and not to test with TDD
- Test your business logic in the interactor
- Test your presentation logic in the presenter
- Test your display logic in view controller and this post
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.
Now how in the world would you use dependency injection to provide a dependency to the view controllers in this application?
What dependency do you want to inject?
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.
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.
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.