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:
First, test that the delegate of the text field is set. It doesn’t matter what it is, as long as it’s not nil.
Second, call the delegate method on the delegate. We do so by asking the text field for its delegate, then calling the method we want.
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:
| 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:
| 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.
![]() |
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:
| 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:
| 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:
| 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:
| 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:).