Test Delegate Methods

Delegate methods are the classic way for Cocoa Touch to call back to our code. Thankfully, we don’t have to try to coax this to happen in test code. For unit tests, it doesn’t matter what calls a delegate method. We only have to mimic the arguments and call the method directly. So what Cocoa Touch would call, the test calls.

Let’s consider how to test the first delegate method, textField(_:shouldChangeCharactersIn:replacementString:). To test with a naive approach, we do two things:

The problem with this approach is that it locks down who gets to be the delegate. Programmers earlier in their Cocoa Touch experience will always use the view controller. This results in a view controller that conforms to several delegate protocols. The common joke in iOS circles is that MVC stands for “Massive View Controller.”

But there’s nothing about the delegate pattern that says you have to do this. A good way to slim down a Massive View Controller is to extract delegates into their own types. With a little care, we can move delegation around without changing any tests. Ideally, tests should strive to check behavior (like what a delegate method does). Tests shouldn’t care about implementation details (like where a delegate method lives). Here’s how to write tests that stay ignorant of the location of delegate methods:

In theory, we could do without the first type of test, since the second type of test will fail if the delegate is nil. But it would be trickier to diagnose. Just as we test outlets with Chapter 7, Testing Outlet Connections, we can save time by testing that we’ve assigned those delegates. Add the following test to check that they’re not nil:

TextField/TextFieldTests/ViewControllerTests.swift
 func​ ​test_textFieldDelegates_shouldBeConnected​() {
 XCTAssertNotNil​(sut.usernameField.delegate, ​"usernameField"​)
 XCTAssertNotNil​(sut.passwordField.delegate, ​"passwordField"​)
 }

Run the tests, which should pass.

Next, let’s test the first delegate method. The job of textField(_:shouldChangeCharactersIn:replacementString:) is to determine whether to replace some text in a text field. It takes a replacement string instead of a single character since the user can paste text. The code prevents the entry of spaces in the username field. We need at least three tests:

(This isn’t intended to be a prescription for how to code your entry fields. I’m just describing what this example does so we can test its behavior.)

Add the following test. Run tests to confirm that it passes:

TextField/TextFieldTests/ViewControllerTests.swift
 func​ ​test_shouldChangeCharacters_usernameWithSpaces_shouldPreventChange​() {
 let​ allowChange = sut.usernameField.delegate?.​textField​?(
  sut.usernameField,
  shouldChangeCharactersIn: ​NSRange​(),
  replacementString: ​"a b"​)
 
 XCTAssertEqual​(allowChange, ​false​)
 }

To reach the text field delegate, we’re not simply calling the view controller. Instead, we ask the username text field for its delegate. This way, the test will continue to work, even if we switch the delegate to another object.

For this test, the replacement string contains a space, as if the user pasted it. We don’t want to allow it to enter this text field.

Since the call uses optional chaining, the result is an optional Bool. So it may be true, false, or nil. We can’t use XCTAssertFalse(_:), which requires a Bool. Instead, we used XCTAssertEqual(_:_:) to assert that the result is false.

images/aside-icons/tip.png

To assert that a Bool? value is true or false, use XCTAssertEqual(_:_:). If there’s a mismatch, it’ll report the actual value.

The test works, but it’s cumbersome to get the text field delegate and then pass that same text field as the first argument. Let’s extract a helper to make the tests more readable. Put this, and other helpers, in a separate file, like TestHelpers.swift, so it’ll be available to other suites:

TextField/TextFieldTests/TestHelpers.swift
 func​ ​shouldChangeCharacters​(​in​ textField: ​UITextField​,
  range: ​NSRange​ = ​NSRange​(),
  replacement: ​String​) -> ​Bool​? {
  textField.delegate?.​textField​?(
  textField,
  shouldChangeCharactersIn: range,
  replacementString: replacement)
 }

Since the range of characters to replace will often (but not always) be empty, it’s handy to give that parameter an empty range as a default value. This simplifies the test code:

TextField/TextFieldTests/ViewControllerTests.swift
 let​ allowChange = ​shouldChangeCharacters​(in: sut.usernameField,
  replacement: ​"a b"​)

Run the tests to confirm that everything is still happy with these changes. Now it’s easy to add a new test that the username field allows text without spaces:

TextField/TextFieldTests/ViewControllerTests.swift
 func​ ​test_shouldChangeCharacters_usernameWithoutSpaces_shouldAllowChange​() {
 let​ allowChange = ​shouldChangeCharacters​(in: sut.usernameField,
  replacement: ​"abc"​)
 
 XCTAssertEqual​(allowChange, ​true​)
 }

Run the tests, which should pass. Finally, we need to test the password field. It should accept all text changes. Since it’s going through the same method that handles the username field, let’s add tests of two replacement strings: one with a space, and one without:

TextField/TextFieldTests/ViewControllerTests.swift
 func​ ​test_shouldChangeCharacters_passwordWithSpaces_shouldAllowChange​() {
 let​ allowChange = ​shouldChangeCharacters​(in: sut.passwordField,
  replacement: ​"a b"​)
 
 XCTAssertEqual​(allowChange, ​true​)
 }
 
 func​ ​test_shouldChangeCharacters_passwordWithoutSpaces_shouldAllowChange​() {
 let​ allowChange = ​shouldChangeCharacters​(in: sut.passwordField,
  replacement: ​"abc"​)
 
 XCTAssertEqual​(allowChange, ​true​)
 }

We now have tests fully covering the text field delegate method textField(_:shouldChangeCharactersIn:replacementString:).