Rehoming View Controller Responsibilities
Open almost any iOS project and you'll find at least one view controller that's become a hoarder’s den: networking responses, validation logic, and UI configuration all crammed together. This tangle makes our apps fragile, difficult to read, harder to test, and painful to extend.
The Single-Responsibility Principle gives us a way out. It states that any unit of work in your project should serve a single purpose. It's a simple idea, but view controllers are where it's most often ignored.
In this post, I'll show how to slim down these view controllers by rehoming functionality into focused types - resulting in cleaner code, clearer boundaries, and easier tests. With this change, our view controllers can focus on what they do best: coordinating between the user interface and the application logic.
Touring the Neighbourhood
Let's look at a simple example where a view controller is doing too much:
@interface WBRegistrationViewController : UIViewController
@property (nonatomic, copy) NSString *userID;
@end
@implementation WBRegistrationViewController
// Omitted other code
#pragma - UITextFieldDelegate
- (void)textFieldDidEndEditing:(UITextField *)textField
{
// Get raw input, defaulting to an empty string if nil
NSString *rawInput = textField.text ?: @"";
// Remove *all* whitespace and newlines (edges + internal)
NSCharacterSet *whitespace = [NSCharacterSet whitespaceAndNewlineCharacterSet];
NSString *cleaned = [[rawInput componentsSeparatedByCharactersInSet:whitespace]
componentsJoinedByString:@""];
// Check that the string only contains digits
NSCharacterSet *nonDigits = [[NSCharacterSet decimalDigitCharacterSet] invertedSet];
BOOL onlyDigits = ([cleaned rangeOfCharacterFromSet:nonDigits].location == NSNotFound);
// Validate length and digit-only requirement
if (onlyDigits && cleaned.length == 12) {
self.userID = cleaned;
} else {
self.userID = nil;
}
}
@end
At first glance, this WBRegistrationViewController
looks fine - the view controller is responding to user input by validating that input and setting its initial state. But notice how much responsibility is packed into textFieldDidEndEditing
:
- Validating input - checking that the input only contains numbers and is of length 12.
- Updating state - setting the
userID
property based on the output from the validation check.
That’s two jobs crammed into one method.
Now let's look at the unit tests required to exercise this validation logic:
@implementation WBRegistrationViewControllerTests
- (void)test_givenAValidUserID_whenUserEntersTheUserID_thenUserIDShouldBeStoredInProperty
{
WBRegistrationViewController *sut = [[WBRegistrationViewController alloc] init];
UITextField *textField = [[UITextField alloc] init];
textField.text = @"012345678912";
[sut textFieldDidEndEditing:textField];
XCTAssertEqualObjects(sut.userID, @"012345678912", @"Property should be set to match the valid user ID")
}
- (void)test_givenAValidUserIDThatContainsSpaces_whenUserEntersTheUserID_thenUserIDShouldBeStoredInProperty
{
WBRegistrationViewController *sut = [[WBRegistrationViewController alloc] init];
UITextField *textField = [[UITextField alloc] init];
textField.text = @" 012345 678912 ";
[sut textFieldDidEndEditing:textField];
XCTAssertEqualObjects(sut.userID, @"012345678912", @"Property should be set to match the valid user ID")
}
- (void)test_givenAnInvalidUserIDThatIsTooShort_whenUserEntersTheUserID_thenUserIDShouldNotBeStoredInProperty
{
WBRegistrationViewController *sut = [[WBRegistrationViewController alloc] init];
UITextField *textField = [[UITextField alloc] init];
textField.text = @"1";
[sut textFieldDidEndEditing:textField];
XCTAssertNil(sut.userID, @"Property should be nil for an invalid user ID");
}
- (void)test_givenAnInvalidUserIDThatIsTooLong_whenUserEntersTheUserID_thenUserIDShouldNotBeStoredInProperty
{
WBRegistrationViewController *sut = [[WBRegistrationViewController alloc] init];
UITextField *textField = [[UITextField alloc] init];
textField.text = @"1000000000000000000";
[sut textFieldDidEndEditing:textField];
XCTAssertNil(sut.userID, @"Property should be nil for an invalid user ID");
}
- (void)test_givenAnInvalidUserIDThatIsEmpty_whenUserEntersTheUserID_thenUserIDShouldNotBeStoredInProperty
{
WBRegistrationViewController *sut = [[WBRegistrationViewController alloc] init];
UITextField *textField = [[UITextField alloc] init];
textField.text = @"";
[sut textFieldDidEndEditing:textField];
XCTAssertNil(sut.userID, @"Property should be nil for an invalid user ID");
}
- (void)test_givenAnInvalidUserIDThatIsNil_whenUserEntersTheUserID_thenUserIDShouldNotBeStoredInProperty
{
WBRegistrationViewController *sut = [[WBRegistrationViewController alloc] init];
UITextField *textField = [[UITextField alloc] init];
textField.text = nil;
[sut textFieldDidEndEditing:textField];
XCTAssertNil(sut.userID, @"Property should be nil for an invalid user ID");
}
- (void)test_givenAnInvalidUserIDThatContainsLetters_whenUserEntersTheUserID_thenUserIDShouldNotBeStoredInProperty
{
WBRegistrationViewController *sut = [[WBRegistrationViewController alloc] init];
UITextField *textField = [[UITextField alloc] init];
textField.text = @"01234a67b912";
[sut textFieldDidEndEditing:textField];
XCTAssertNil(sut.userID, @"Property should be nil for an invalid user ID");
}
@end
These tests work, but notice the overhead to test the validation - we have to create a UITextField
instance just to pass a string into the delegate method. We're testing validation through UIKit
plumbing. What we really want to test is just the validation itself - but because it lives inside the view controller, we're forced to drag UIKit
into the picture. By burying validation inside the view controller, we've made both the code and the tests heavier than they need to be.
Let's see how applying the Single-Responsibility Principle
can simplify things by rehoming the validation rules into their own dedicated type - WBUserDetailsValidator
.
Settling Functionality into Its New Home
Let's look at what WBUserDetailsValidator
does:
@implementation WBUserDetailsValidator
- (BOOL)isValidUserID:(NSString *)userID
{
// Get raw input, defaulting to an empty string if nil
NSString *rawInput = userID ?: @"";
// Remove *all* whitespace and newlines (edges + internal)
NSCharacterSet *whitespace = [NSCharacterSet whitespaceAndNewlineCharacterSet];
NSString *cleaned = [[rawInput componentsSeparatedByCharactersInSet:whitespace]
componentsJoinedByString:@""];
// Check that the string only contains digits
NSCharacterSet *nonDigits = [[NSCharacterSet decimalDigitCharacterSet] invertedSet];
BOOL onlyDigits = ([cleaned rangeOfCharacterFromSet:nonDigits].location == NSNotFound);
// Validate length and digit-only requirement
return (onlyDigits && cleaned.length == 12);
}
@end
The above method isValidUserID:
contains only the logic to know if a user ID is valid. WBUserDetailsValidator
contains absolutely no knowledge about how it's used or where the data comes from - this is good, as this means that the tests don't need to contain that knowledge either:
@implementation WBUserDetailsValidatorTests
- (void)test_givenAValidUserID_whenisValidIsCalled_thenShouldReturnYES
{
WBUserDetailsValidator *sut = [[WBUserDetailsValidator alloc] init];
BOOL isValid = [sut isValidUserID:@"012345678912"];
XCTAssertTrue(isValid, @"True should be returned for a valid user ID")
}
- (void)test_givenAValidUserIDThatContainsSpaces_whenisValidIsCalled_thenUserIDShouldBeStoredInProperty
{
WBUserDetailsValidator *sut = [[WBUserDetailsValidator alloc] init];
BOOL isValid = [sut isValidUserID:@" 012345 678912 "];
XCTAssertTrue(isValid, @"True should be returned for a valid user ID")
}
- (void)test_givenAnInvalidUserIDThatIsTooShort_whenisValidIsCalled_thenShouldReturnNO
{
WBUserDetailsValidator *sut = [[WBUserDetailsValidator alloc] init];
BOOL isValid = [sut isValidUserID:@"1"];
XCTAssertFalse(isValid, @"False should be returned for an invalid user ID");
}
- (void)test_givenAnInvalidUserIDThatIsTooLong_whenisValidIsCalled_thenShouldReturnNO
{
WBUserDetailsValidator *sut = [[WBUserDetailsValidator alloc] init];
BOOL isValid = [sut isValidUserID:@"1000000000000000000"];
XCTAssertFalse(isValid, @"False should be returned for an invalid user ID");
}
- (void)test_givenAnInvalidUserIDThatIsEmpty_whenisValidIsCalled_thenShouldReturnNO
{
WBUserDetailsValidator *sut = [[WBUserDetailsValidator alloc] init];
BOOL isValid = [sut isValidUserID: @""];
XCTAssertFalse(isValid, @"False should be returned for an invalid user ID");
}
- (void)test_givenAnInvalidUserIDThatIsNil_whenisValidIsCalled_thenShouldReturnNO
{
WBUserDetailsValidator *sut = [[WBUserDetailsValidator alloc] init];
BOOL isValid = [sut isValidUserID:nil];
XCTAssertFalse(isValid, @"False should be returned for an invalid user ID");
}
- (void)test_givenAnInvalidUserIDThatContainsLetters_whenisValidIsCalled_thenShouldReturnNO
{
WBUserDetailsValidator *sut = [[WBUserDetailsValidator alloc] init];
BOOL isValid = [sut isValidUserID:@"01234a67b912"];
XCTAssertFalse(isValid, @"False should be returned for an invalid user ID");
}
@end
The above tests are simpler than what we saw before in WBRegistrationViewController
, as all UIKit
plumbing has been removed.
Let's update WBRegistrationViewController
to use WBUserDetailsValidator
:
@interface WBRegistrationViewController : UIViewController
// Omitted other properties
@property (nonatomic, strong) WBUserDetailsValidator *validator;
@end
@implementation WBRegistrationViewController
// Omitted other code
#pragma - UITextFieldDelegate
- (void)textFieldDidEndEditing:(UITextField *)textField
{
if ([self.validator isValidUserID:textField.text])
{
self.userID = textField.text;
}
else
{
self.userID = nil;
}
}
@end
This change has significantly simplified textFieldDidEndEditing:
, but we can push this further. Right now, our view controller tests would still need all 7
test cases because the paths through the method still depend on the validation rules - just more indirectly than before.
We can solve this with a bit of sprinkling Polymorphism
via a Protocol
.
Decorating with a Protocol
Rather than directly passing an instance of WBUserDetailsValidator
into WBRegistrationViewController
, we can pass a protocol that describes what a validator does to the view controller. This protocol will allow for the real validator to be passed in during production and a test double to be passed in during testing. This test double gives us complete control over validation outcomes, resulting in fewer unit tests needing to be written for textFieldDidEndEditing:
.
This new protocol will mimic the current WBUserDetailsValidator
interface:
@protocol WBUserDetailsValidating
- (BOOL)isValidUserID:(NSString *)userID;
WBUserDetailsValidator
then conforms to the protocol by:
@interface WBUserDetailsValidator : NSObject
@end
Now that we have wrapped WBUserDetailsValidator
in the WBUserDetailsValidating
protocol, we change WBRegistrationViewController
to use the protocol rather than the concrete type:
@interface WBRegistrationViewController : UIViewController
// Omitted other properties
@property (nonatomic, strong) id<WBUserDetailsValidating> validator;
@end
@implementation WBRegistrationViewController
// Same as before
@end
With that change in place, we can turn our attention to the unit tests.
First, we need to create our test double of WBUserDetailsValidating
to control better what happens in the unit tests:
@interface WBStubUserDetailsValidator : NSObject
@property (nonatomic, assign) BOOL stubbedResult;
@end
@implementation WBStubUserDetailsValidator
- (instancetype)init {
self = [super init];
if (self)
{
_stubbedResult = YES; // Default behaviour
}
return self;
}
- (BOOL)isValidUserID:(NSString *)userID
{
return self.stubbedResult;
}
@end
WBStubUserDetailsValidator
allows us to set the outcome of the validation without the validation actually needing to occur. To control the outcome, we set the stubbedResult
property with the response that we want to return.
Now that we have our test double, let's make use of it:
@implementation WBRegistrationViewControllerTests
- (void)test_givenAValidUserID_whenUserEntersTheUserID_thenUserIDShouldBeStoredInProperty
{
WBStubUserDetailsValidator *validator = [[WBStubUserDetailsValidator alloc] init];
validator.stubbedResult = YES; // returns YES when `isValidUserID` is called
WBRegistrationViewController *sut = [[WBRegistrationViewController alloc] init];
sut.validator = validator;
UITextField *textField = [[UITextField alloc] init];
textField.text = @"test_user_id";
[sut textFieldDidEndEditing:textField];
XCTAssertEqualObjects(sut.userID, @"test_user_id", @"Property should be set to match the valid user ID")
}
- (void)test_givenAnInvalidUserID_whenUserEntersTheUserID_thenUserIDShouldNotBeStoredInProperty
{
WBStubUserDetailsValidator *validator = [[WBStubUserDetailsValidator alloc] init];
validator.stubbedResult = NO; // returns NO when `isValidUserID` is called
WBRegistrationViewController *sut = [[WBRegistrationViewController alloc] init];
sut.validator = validator;
UITextField *textField = [[UITextField alloc] init];
textField.text = @"test_user_id";
[sut textFieldDidEndEditing:textField];
XCTAssertNil(sut.userID, @"Property should be nil for an invalid user ID");
}
@end
To pass the test double, we are using a technique called
Dependency Injection
. If you want to learn more about the various types ofDependency Injection
, readLet Dependency Injection Lead You to a Better Design
.
Each test has slightly more code, but the number of tests required is down from 7
to 2
. As our view controller no longer cares about what makes a string a valid user ID, it no longer has to test those paths; instead, it focuses on the two possible validation outcomes, YES
or NO
, and we only have to write tests for those two paths.
With validation separated into its own type, free of UIKit
, we've simplified both the production code and test code by sticking to the Single-Responsibility Principle
.
A Tidy, Peaceful Home
This example was just validating a user ID, but the same principle applies to networking, persistence, analytics, and beyond. Your view controllers don’t want to be hoarders. Help them declutter, give each unneeded responsibility a home of its own, and each view controller will be freer for it.