Rehoming View Controller Responsibilities

05 Jan 2013 16 min read

Everyone has that chair. It starts innocently enough: one t-shirt draped over the back, because hanging it up felt like more effort than the t-shirt was worth. Then a jumper joins it, because the t-shirt was already there. Then a pair of jeans, a hoodie, that top you might wear again, and somewhere along the way, the chair stops being something to sit on and becomes a wardrobe. You can't get to that original t-shirt because to try and pull it out risks triggering an avalanche that you might not survive.

View controllers in our apps often end up like that chair. A delegate callback looks like the obvious home for a quick validation check, so in it goes. Normalisation follows, then some state, each one as reasonable as the last - until the day you try to unit test the newest change and realise it's buried under the whole pile, unreachable without dragging UIKit and any number of other frameworks along with it.

A chair lost beneath a mountain of clothes

The fix is the same for both your bedroom and your view controller: give every item a home. The Single-Responsibility Principle (SRP) says each unit of work should serve a single purpose. In this post, we'll apply SRP to one overloaded view controller - rehoming validation and normalisation into focused types, handing coordination and state to a view model, and leaving the view controller doing nothing but reacting to the user. Each piece we lift off the pile becomes something we can finally test on its own.

Sorting Through the Pile

Let's take a registration screen that needs to capture and validate a user's ID:

@interface WBRegistrationViewController : UIViewController

@property (nonatomic, copy) NSString *userID;

@end

@implementation WBRegistrationViewController

// Omitted other code

- (void)textFieldDidEndEditing:(UITextField *)textField
{
    // Get raw input, defaulting to an empty string if nil
    NSString *rawInput = textField.text ?: @"";

    // Normalise by removing *all* whitespace and newlines (edges + internal)
    NSCharacterSet *whitespace = [NSCharacterSet whitespaceAndNewlineCharacterSet];
    NSString *normalised = [[rawInput componentsSeparatedByCharactersInSet:whitespace] componentsJoinedByString:@""];

    // Check that the string only contains digits
    NSCharacterSet *nonDigits = [[NSCharacterSet decimalDigitCharacterSet] invertedSet];
    BOOL onlyDigits = ([normalised rangeOfCharacterFromSet:nonDigits].location == NSNotFound);

    // Validate length and digit-only requirement
    if (onlyDigits && normalised.length == 12) {
        self.userID = normalised;
    } else {
        self.userID = nil;
        [self showErrorMessage];
    }
}

@end

We've all seen code like this many times: UIViewController conforms to UITextFieldDelegate to receive feedback when a user stops typing into a UITextField, and that input is then validated and stored. Let's see what the unit tests look like to support this functionality:

@implementation WBRegistrationViewControllerTests

// Omitted non `textFieldDidEndEditing:` tests

- (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 the cleaned user ID with spaces removed");
}

- (void)test_givenAValidUserIDThatContainsLeadingAndTrailingWhitespace_whenUserEntersTheUserID_thenCleanedUserIDShouldBeStoredInProperty
{
    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 the cleaned user ID with leading and trailing whitespace removed");
}

- (void)test_givenAValidUserIDThatContainsNewlinesAndTabs_whenUserEntersTheUserID_thenCleanedUserIDShouldBeStoredInProperty
{
    WBRegistrationViewController *sut = [[WBRegistrationViewController alloc] init];

    UITextField *textField = [[UITextField alloc] init];
    textField.text = @"0123\n4567\t8912";

    [sut textFieldDidEndEditing:textField];

    XCTAssertEqualObjects(sut.userID, @"012345678912", @"Property should be set to the cleaned user ID with newlines and tabs removed");
}

- (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_givenAnInvalidUserIDThatIsOneDigitTooShort_whenUserEntersTheUserID_thenUserIDShouldNotBeStoredInProperty
{
    WBRegistrationViewController *sut = [[WBRegistrationViewController alloc] init];

    UITextField *textField = [[UITextField alloc] init];
    textField.text = @"01234567891";

    [sut textFieldDidEndEditing:textField];

    XCTAssertNil(sut.userID, @"Property should be nil for a user ID that is one digit too short");
}

- (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_givenAnInvalidUserIDThatIsOneDigitTooLong_whenUserEntersTheUserID_thenUserIDShouldNotBeStoredInProperty
{
    WBRegistrationViewController *sut = [[WBRegistrationViewController alloc] init];

    UITextField *textField = [[UITextField alloc] init];
    textField.text = @"0123456789123";

    [sut textFieldDidEndEditing:textField];

    XCTAssertNil(sut.userID, @"Property should be nil for a user ID that is one digit too long");
}

- (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_givenAnInvalidUserIDThatIsAllWhitespace_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 a user ID that is all whitespace");
}

- (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

Twelve tests aren't unreasonable for this much behaviour. But look closer, and we see that every test has to build a UITextField to exercise the normalisation and validation code. For most of these tests, we don't actually care about the UITextField - it's only a vehicle for feeding different string configurations into textFieldDidEndEditing: so we can check how it reacts.

I haven't shown the tests that assert that showErrorMessage is correctly being called. This is to keep the scope of this post tightly focused on the text manipulation functionality.

Tests get heavy like this when they're forced to reach through one thing to exercise another. To understand why, let's break down what textFieldDidEndEditing: is actually doing.

The method has four responsibilities:

  1. Reacting to user events - acting as the delegate for a text field and presenting feedback to the user.
  2. Normalising input - stripping leading, trailing and internal whitespace.
  3. Validating input - checking the result is twelve digits and nothing else.
  4. Updating state - setting userID to the normalised value or nil depending on how validation went.

Four responsibilities in one method is precisely what SRP warns against - a unit of work should serve a single purpose. Only one of these belongs to the view controller - reacting to user events. Normalising a string, deciding whether it's a valid user ID, and holding state do not; they've just piled up in WBRegistrationViewController because it's a convenient place to put them. We need to find new homes for those three.

Looking at What We Need to Build

Before jumping into building each component, let's look at the overall architecture we're going to put in place and how each responsibility/component fits into it.

Class diagram showing WBRegistrationViewController depending on WBRegistrationViewModel, which uses WBInputNormaliser and WBUserIDValidator

  • WBRegistrationViewController is the view controller that responds to user interactions by acting as the UITextFieldDelegate.
  • WBRegistrationViewModel orchestrates normalisation, validation and state exposure to the view controller.
  • WBInputNormaliser strips any noise characters from the user's input and returns a cleaned version of it.
  • WBUserIDValidator performs validation on the user's input.

Don't worry if that doesn't all make sense yet; we will look into each component in greater depth below.

The architecture shown below is a slice of Model-View-ViewModel (MVVM). If it's new to you, it splits a screen into a model that owns the data and rules, a view that displays state and forwards interaction, and a view-model between them that holds the view's state. Keeping that state out of the view is what lets the view-model be tested without a UI framework in the way.

Now that we know where we are going, let's start by implementing input validation.

Validating

From the current validation implementation in WBRegistrationViewController, we know that a valid user ID consists of exactly 12 digits - no letters, no symbols and no whitespace. So let's extract that functionality into its own type:

// 1
@interface WBUserIDValidator : NSObject

- (BOOL)isValidUserID:(NSString *)userID;

@end

@implementation WBUserIDValidator

- (BOOL)isValidUserID:(NSString *)userID
{
    // 2
    NSString *rawInput = userID ?: @"";

    // 3
    NSCharacterSet *nonDigits = [[NSCharacterSet decimalDigitCharacterSet] invertedSet];
    BOOL onlyDigits = ([rawInput rangeOfCharacterFromSet:nonDigits].location == NSNotFound);

    // 4
    return (onlyDigits && rawInput.length == 12);
}

@end

Here's what we extracted:

  1. WBUserIDValidator is a small, stateless type with a single method, isValidUserID:, so the validity rule has one home and nothing else needs to know how it works.
  2. A caller can pass nil as userID, so we fall back to an empty string - an empty string fails the rule anyway, which spares us special-casing nil later.
  3. Inverting decimalDigitCharacterSet gives us the set of everything that isn't a digit, so if rangeOfCharacterFromSet: finds none of those, rawInput is digits-only.
  4. A valid user ID has to clear both bars - digits-only and exactly twelve characters - so we return the two checks combined.

We can now write unit tests to verify that validation is working as expected without needing to use UITextField:

@implementation WBUserIDValidatorTests

- (void)test_givenAValidUserID_whenIsValidIsCalled_thenShouldReturnYES
{
    WBUserIDValidator *sut = [[WBUserIDValidator alloc] init];

    BOOL isValid = [sut isValidUserID:@"012345678912"];

    XCTAssertTrue(isValid, @"True should be returned for a valid user ID");
}

- (void)test_givenAnInvalidUserIDThatIsTooShort_whenIsValidIsCalled_thenShouldReturnNO
{
    WBUserIDValidator *sut = [[WBUserIDValidator alloc] init];

    BOOL isValid = [sut isValidUserID:@"1"];

    XCTAssertFalse(isValid, @"False should be returned for a user ID that is too short");
}

- (void)test_givenAnInvalidUserIDThatIsTooLong_whenIsValidIsCalled_thenShouldReturnNO
{
    WBUserIDValidator *sut = [[WBUserIDValidator alloc] init];

    BOOL isValid = [sut isValidUserID:@"1000000000000000000"];

    XCTAssertFalse(isValid, @"False should be returned for a user ID that is too long");
}

- (void)test_givenAnInvalidUserIDThatIsEmpty_whenIsValidIsCalled_thenShouldReturnNO
{
    WBUserIDValidator *sut = [[WBUserIDValidator alloc] init];

    BOOL isValid = [sut isValidUserID:@""];

    XCTAssertFalse(isValid, @"False should be returned for an empty user ID");
}

- (void)test_givenAnInvalidUserIDThatIsNil_whenIsValidIsCalled_thenShouldReturnNO
{
    WBUserIDValidator *sut = [[WBUserIDValidator alloc] init];

    BOOL isValid = [sut isValidUserID:nil];

    XCTAssertFalse(isValid, @"False should be returned for a nil user ID");
}

- (void)test_givenAnInvalidUserIDThatContainsLetters_whenIsValidIsCalled_thenShouldReturnNO
{
    WBUserIDValidator *sut = [[WBUserIDValidator alloc] init];

    BOOL isValid = [sut isValidUserID:@"01234a67b912"];

    XCTAssertFalse(isValid, @"False should be returned for a user ID containing letters");
}

@end

Compared to their equivalents in the original WBRegistrationViewController, these tests are far simpler - a string in, a value out, no UITextField to build first.

That's validation rehomed, let's move on to normalisation.

Normalising

From the current normalisation implementation in WBRegistrationViewController, we know that normalisation of the user's input involves stripping out all whitespace. So let's extract that functionality into its own type:

// 1
@interface WBInputNormaliser : NSObject

- (NSString *)normaliseInput:(NSString *)input;

@end

@implementation WBInputNormaliser

- (NSString *)normaliseInput:(NSString *)input
{
    // 2
    NSString *rawInput = input ?: @"";

    // 3
    NSCharacterSet *whitespace = [NSCharacterSet whitespaceAndNewlineCharacterSet];
    return [[rawInput componentsSeparatedByCharactersInSet:whitespace]
            componentsJoinedByString:@""];
}

@end
  1. WBInputNormaliser is a small, stateless type with a single method, normaliseInput:, so cleaning input has one home and the validity rule can live somewhere else entirely.
  2. A caller can pass nil as input, so we fall back to an empty string - an empty string normalises to an empty string, which spares us special-casing nil later.
  3. Splitting on whitespaceAndNewlineCharacterSet and rejoining with no separator strips every space, tab and newline in one pass - preceding, trailing and internal.
@implementation WBInputNormaliserTests

- (void)test_givenInputWithoutWhitespace_whenNormaliseInputIsCalled_thenShouldReturnUnchanged
{
    WBInputNormaliser *sut = [[WBInputNormaliser alloc] init];

    NSString *normalised = [sut normaliseInput:@"012345678912"];

    XCTAssertEqualObjects(normalised, @"012345678912", @"Input without whitespace should be returned unchanged");
}

- (void)test_givenInputWithInternalWhitespace_whenNormaliseInputIsCalled_thenWhitespaceShouldBeRemoved
{
    WBInputNormaliser *sut = [[WBInputNormaliser alloc] init];

    NSString *normalised = [sut normaliseInput:@" 012345 678912 "];

    XCTAssertEqualObjects(normalised, @"012345678912", @"Internal whitespace should be removed");
}

- (void)test_givenInputWithLeadingAndTrailingWhitespace_whenNormaliseInputIsCalled_thenWhitespaceShouldBeRemoved
{
    WBInputNormaliser *sut = [[WBInputNormaliser alloc] init];

    NSString *normalised = [sut normaliseInput:@"  012345678912  "];

    XCTAssertEqualObjects(normalised, @"012345678912", @"Leading and trailing whitespace should be removed");
}

- (void)test_givenInputWithNewlinesAndTabs_whenNormaliseInputIsCalled_thenWhitespaceShouldBeRemoved
{
    WBInputNormaliser *sut = [[WBInputNormaliser alloc] init];

    NSString *normalised = [sut normaliseInput:@"0123\n4567\t8912"];

    XCTAssertEqualObjects(normalised, @"012345678912", @"Newlines and tabs should be removed");
}

- (void)test_givenEmptyInput_whenNormaliseInputIsCalled_thenShouldReturnEmptyString
{
    WBInputNormaliser *sut = [[WBInputNormaliser alloc] init];

    NSString *normalised = [sut normaliseInput:@""];

    XCTAssertEqualObjects(normalised, @"", @"Empty input should return an empty string");
}

- (void)test_givenNilInput_whenNormaliseInputIsCalled_thenShouldReturnEmptyString
{
    WBInputNormaliser *sut = [[WBInputNormaliser alloc] init];

    NSString *normalised = [sut normaliseInput:nil];

    XCTAssertEqualObjects(normalised, @"", @"Nil input should return an empty string");
}

- (void)test_givenInputThatIsAllWhitespace_whenNormaliseInputIsCalled_thenShouldReturnEmptyString
{
    WBInputNormaliser *sut = [[WBInputNormaliser alloc] init];

    NSString *normalised = [sut normaliseInput:@"   "];

    XCTAssertEqualObjects(normalised, @"", @"Input that is all whitespace should return an empty string");
}

@end

The same simplicity carries over - each test is a string in and a string out, with no UITextField to build first.

Having now seen how small WBUserIDValidator and WBInputNormaliser are, you might be tempted to fold them into a single type - but they change for different reasons. The day the validity rules tighten, WBUserIDValidator changes and WBInputNormaliser doesn't. One reason to change per type is a tenet of SRP.

With validation and normalisation rehomed, let's move on to coordination.

Coordinating

Currently, WBRegistrationViewController handles coordination, normalisation, validation and state. We've already rehomed normalisation and validation; now it's time to do the same for coordination and state:

// 1
@interface WBRegistrationViewModel : NSObject

// 2
@property (nonatomic, copy) NSString *userID;

// 3
@property (nonatomic, strong) WBUserIDValidator *validator;

// 4
@property (nonatomic, strong) WBInputNormaliser *normaliser;

- (BOOL)updateUserIDFromInput:(NSString *)userID;

@end

@implementation WBRegistrationViewModel

- (BOOL)updateUserIDFromInput:(NSString *)userID
{
    // 5
    NSString *normalisedUserID = [self.normaliser normaliseInput:userID];
    BOOL isValid = [self.validator isValidUserID:normalisedUserID];

    // 6
    if (isValid)
    {
        self.userID = normalisedUserID;
    }
    else
    {
        self.userID = nil;
    }

    // 7
    return isValid;
}

@end

Here's what we did:

  1. WBRegistrationViewModel exists to hold the registration screen's state and coordinate the work the view controller used to do inline, freeing the view controller to handle nothing but user interaction.
  2. userID is now the view model's state rather than the view controller's - this property is the whole reason the view model exists.
  3. The view model is handed a validator, so it can ask whether an ID is valid without owning the rule itself.
  4. It's handed a normaliser too, so cleaning the input isn't its concern either - it just orchestrates the two collaborators.
  5. The raw input is normalised first, and the cleaned value is what gets validated, preserving the normalise-before-validate ordering.
  6. On success, the normalised value becomes the stored userID; on failure userID is cleared to nil, so the property always reflects the latest attempt.
  7. The pass/fail result is returned so the view controller can update the UI, while the view model has already recorded the state itself.

Extracting responsibilities into separate types solves one problem, but it introduces another: how do we verify the view model coordinates those types correctly without executing their real behaviour? To answer that, we need a way to replace collaborators during testing.

Making the Collaborators Swappable

WBRegistrationViewModel doesn't build its own normaliser and validator - it's handed them through its normaliser and validator properties and works with whatever it's given. That handing-in is dependency injection: the collaborators are supplied from the outside rather than constructed within updateUserIDFromInput:. In production, the view model is handed the real normaliser and validator; in a test, it can be handed test doubles that have the same interface as the real types to allow for tighter control of what's actually under test. Because WBRegistrationViewModel only ever validates and normalises through those properties, the view model can't tell the difference - which is what lets a test set the normalisation and validation outcomes without either running for real.

If you want to learn more about the various types of dependency injection, read Let Dependency Injection Lead You to a Better Design.

At the moment, in order to pass in test doubles for WBUserIDValidator and WBInputNormaliser, we would need to subclass these types and override their methods. Subclassing is a form of polymorphism, but things can get messy quickly when relying on having to remember to override everything; protocols offer a cleaner way to introduce polymorphism.

Polymorphism is the ability for a single declared type to be satisfied by different concrete types. Code written against that declared type doesn't know or care which one it's actually given - it relies only on the shared set of methods the concrete types promise to provide. This is what makes it possible to swap one implementation for another without the calling code changing at all - it's written against the contract, not the class behind it.

In iOS, a protocol is a form of polymorphism where the protocol expresses a contract of functionality without actually detailing how that functionality should be implemented. Any type can adopt it by conforming to and implementing that contract. Protocols let you describe what a collaborator must do without naming who does it - so different concrete types can stand in depending on what the moment calls for. We can then express this in properties via the id syntax: id<SomeProtocol>. The property declares the protocol instead of a concrete class - the compiler checks only that whatever is assigned conforms to that protocol. Two unrelated types, sharing nothing but their conformance to the same protocol, are now interchangeable.

We can use protocols to make WBRegistrationViewModel more testable by allowing test doubles to replace WBUserIDValidator and WBInputNormaliser. First, we need to define protocols to cover validating and normalising:

// 1
@protocol WBUserIDValidating <NSObject>

- (BOOL)isValidUserID:(NSString *)userID;

@end

// 2
@protocol WBInputNormalising <NSObject>

- (NSString *)normaliseInput:(NSString *)input;

@end
  1. WBUserIDValidating turns "can tell whether a user ID is valid" into a contract: it declares isValidUserID: and adopts NSObject so conformers behave as ordinary objects, letting callers depend on the capability rather than on WBUserIDValidator itself.
  2. WBInputNormalising does the same for cleaning input, declaring normaliseInput: so the real normaliser and any test double are interchangeable wherever the contract is expected.

With these protocols, we can now update WBRegistrationViewModel to make use of them:

@interface WBRegistrationViewModel : NSObject

// Omitted other properties
@property (nonatomic, strong) id<WBUserIDValidating> validator;
@property (nonatomic, strong) id<WBInputNormalising> normaliser;

@end

No changes are required in updateUserIDFromInput:.

All that is left to do on the production side is to make WBUserIDValidator and WBInputNormaliser conform to these protocols. As both already have the same methods defined in the protocols, conforming is as simple as declaring it:

@interface WBUserIDValidator : NSObject <WBUserIDValidating>
@end

@interface WBInputNormaliser : NSObject <WBInputNormalising>
@end

Dependency injection and the two protocols exist for one reason: so that, in tests, WBRegistrationViewModel runs against doubles rather than the real WBUserIDValidator and WBInputNormaliser. So we need test doubles that conform to WBUserIDValidating and WBInputNormalising for the tests to substitute in.

// 1
@interface WBStubUserIDValidator : NSObject <WBUserIDValidating>

// 2
@property (nonatomic, assign) BOOL stubbedIsValidUserID;

// 3
@property (nonatomic, copy) NSString *capturedUserID;

@end

@implementation WBStubUserIDValidator

// 4
- (instancetype)init
{
    self = [super init];

    if (self)
    {
        _stubbedIsValidUserID = YES;
    }

    return self;
}

// 5
- (BOOL)isValidUserID:(NSString *)userID
{
    self.capturedUserID = userID;

    return self.stubbedIsValidUserID;
}

@end
  1. WBStubUserIDValidator conforms to WBUserIDValidating, so wherever the view model expects something that validates, this double slots in and the view model can't tell it isn't the real validator.
  2. stubbedIsValidUserID lets a test fix the validation outcome up front, so the view model receives a known result without any real validation running.
  3. capturedUserID records whatever the double was handed, which is how a test later checks the view model passed the normalised value to the validator rather than the raw input.
  4. The init defaults stubbedIsValidUserID to YES, so a test only has to set it when it cares about the failing case - the passing path comes for free.
  5. isValidUserID: captures the input and then returns the stubbed value instead of doing any real checking.
// 1
@interface WBStubInputNormaliser : NSObject <WBInputNormalising>

// 2
@property (nonatomic, copy) NSString *stubbedNormaliseInput;

// 3
@property (nonatomic, copy) NSString *capturedInput;

@end

@implementation WBStubInputNormaliser

// 4
- (instancetype)init
{
    self = [super init];

    if (self)
    {
        _stubbedNormaliseInput = @"";
    }

    return self;
}

// 5
- (NSString *)normaliseInput:(NSString *)input
{
    self.capturedInput = input;

    return self.stubbedNormaliseInput;
}

@end
  1. WBStubInputNormaliser conforms to WBInputNormalising, so wherever the view model expects something that normalises input, this double slots in and the view model can't tell it isn't the real normaliser.
  2. stubbedNormaliseInput lets a test fix the cleaned output up front, so the view model receives a known value without any real normalising running.
  3. capturedInput records whatever the double was handed, which is how a test later checks the view model passed the raw text field value to the normaliser before anything was cleaned.
  4. The init defaults stubbedNormaliseInput to an empty string, so a test only sets it when the cleaned value matters - everything else gets a sensible default.
  5. normaliseInput: captures the input and then returns the stubbed value instead of doing any real cleaning.

WBStubInputNormaliser and WBStubUserIDValidator aren't strictly stubs but more a stub/spy hybrid - but WBStubSpyInputNormaliser would just be a truly awful name.

Testing the View Model

With both test doubles in place, we can now write our WBRegistrationViewModel unit tests:

@implementation WBRegistrationViewModelTests

- (void)test_givenValidationSucceeds_whenUpdateUserIDFromInputIsCalled_thenNormalisedUserIDShouldBeStored
{
    WBStubInputNormaliser *normaliser = [[WBStubInputNormaliser alloc] init];
    normaliser.stubbedNormaliseInput = @"012345678912";

    WBStubUserIDValidator *validator = [[WBStubUserIDValidator alloc] init];
    validator.stubbedIsValidUserID = YES;

    WBRegistrationViewModel *sut = [[WBRegistrationViewModel alloc] init];
    sut.normaliser = normaliser;
    sut.validator = validator;

    [sut updateUserIDFromInput:@" raw input "];

    XCTAssertEqualObjects(sut.userID, @"012345678912", @"Property should be set to the normalised user ID when validation succeeds");
}

- (void)test_givenValidationSucceeds_whenUpdateUserIDFromInputIsCalled_thenShouldReturnYES
{
    WBStubInputNormaliser *normaliser = [[WBStubInputNormaliser alloc] init];
    normaliser.stubbedNormaliseInput = @"012345678912";

    WBStubUserIDValidator *validator = [[WBStubUserIDValidator alloc] init];
    validator.stubbedIsValidUserID = YES;

    WBRegistrationViewModel *sut = [[WBRegistrationViewModel alloc] init];
    sut.normaliser = normaliser;
    sut.validator = validator;

    BOOL isValid = [sut updateUserIDFromInput:@" raw input "];

    XCTAssertTrue(isValid, @"YES should be returned when validation succeeds");
}

- (void)test_givenValidationFails_whenUpdateUserIDFromInputIsCalled_thenUserIDShouldNotBeStored
{
    WBStubInputNormaliser *normaliser = [[WBStubInputNormaliser alloc] init];
    normaliser.stubbedNormaliseInput = @"012345678912";

    WBStubUserIDValidator *validator = [[WBStubUserIDValidator alloc] init];
    validator.stubbedIsValidUserID = NO;

    WBRegistrationViewModel *sut = [[WBRegistrationViewModel alloc] init];
    sut.normaliser = normaliser;
    sut.validator = validator;

    [sut updateUserIDFromInput:@" raw input "];

    XCTAssertNil(sut.userID, @"Property should be nil when validation fails");
}

- (void)test_givenValidationFails_whenUpdateUserIDFromInputIsCalled_thenShouldReturnNO
{
    WBStubInputNormaliser *normaliser = [[WBStubInputNormaliser alloc] init];
    normaliser.stubbedNormaliseInput = @"012345678912";

    WBStubUserIDValidator *validator = [[WBStubUserIDValidator alloc] init];
    validator.stubbedIsValidUserID = NO;

    WBRegistrationViewModel *sut = [[WBRegistrationViewModel alloc] init];
    sut.normaliser = normaliser;
    sut.validator = validator;

    BOOL isValid = [sut updateUserIDFromInput:@" raw input "];

    XCTAssertFalse(isValid, @"NO should be returned when validation fails");
}

- (void)test_whenUpdateUserIDFromInputIsCalled_thenInputShouldBeNormalisedBeforeBeingValidated
{
    WBStubInputNormaliser *normaliser = [[WBStubInputNormaliser alloc] init];
    normaliser.stubbedNormaliseInput = @"012345678912";

    WBStubUserIDValidator *validator = [[WBStubUserIDValidator alloc] init];
    validator.stubbedIsValidUserID = YES;

    WBRegistrationViewModel *sut = [[WBRegistrationViewModel alloc] init];
    sut.normaliser = normaliser;
    sut.validator = validator;

    [sut updateUserIDFromInput:@" raw input "];

    XCTAssertEqualObjects(normaliser.capturedInput, @" raw input ", @"Normaliser should receive the raw input value");
    XCTAssertEqualObjects(validator.capturedUserID, @"012345678912", @"Validator should receive the normalised value, not the raw input");
}

@end

These tests have much in common with the tests that we saw in WBRegistrationViewControllerTests, but they are less complex as they don't interact with UITextField.

That's coordination rehomed, what's left is reacting.

Reacting

Before we look at what's left of the view controller, WBRegistrationViewModel needs the same swappable treatment we gave its collaborators to allow for unit testing WBRegistrationViewController:

@protocol WBRegistrationViewModeling <NSObject>

- (BOOL)updateUserIDFromInput:(NSString *)userID;

@end

@interface WBRegistrationViewModel : NSObject <WBRegistrationViewModeling>
@end

And the matching stub:

@interface WBStubRegistrationViewModel : NSObject <WBRegistrationViewModeling>

@property (nonatomic, assign) BOOL stubbedUpdateUserIDFromInput;
@property (nonatomic, copy) NSString *capturedUserID;

@end

@implementation WBStubRegistrationViewModel

- (instancetype)init
{
    self = [super init];

    if (self)
    {
        _stubbedUpdateUserIDFromInput = YES;
    }

    return self;
}

- (BOOL)updateUserIDFromInput:(NSString *)userID
{
    self.capturedUserID = userID;

    return self.stubbedUpdateUserIDFromInput;
}

@end

With that in place, we can return to WBRegistrationViewController. Stripped of three of its four responsibilities, there isn't much left:

@interface WBRegistrationViewController : UIViewController

// 1
@property (nonatomic, strong) id<WBRegistrationViewModeling> viewModel;

@end

@implementation WBRegistrationViewController

// Omitted other code

- (void)textFieldDidEndEditing:(UITextField *)textField
{
    // 2
    if (![self.viewModel updateUserIDFromInput:textField.text])
    {
        [self showErrorMessage];
    }
}

@end
  1. The view controller owns a single collaborator and leans on it for the normalising, validating and state-keeping.
  2. When editing ends, the controller hands the raw text straight to the view model and acts only on the answer: if the ID isn't valid, it shows an error.

Notice what WBRegistrationViewController no longer does: it doesn't normalise, validate, or store anything. It reacts to a UIKit callback and drives the UI, which is all a view controller should be doing. Without losing any functionality from a user point-of-view, we have significantly reduced the scope of WBRegistrationViewController. This reduction is also felt in the WBRegistrationViewControllerTests:

@implementation WBRegistrationViewControllerTests

// Omitted non `textFieldDidEndEditing:` tests

- (void)test_givenUserHasEnteredUserID_whenEditingEnds_thenViewModelShouldBeAskedToValidateTheRawText
{
    WBStubRegistrationViewModel *viewModel = [[WBStubRegistrationViewModel alloc] init];

    WBRegistrationViewController *sut = [[WBRegistrationViewController alloc] init];
    sut.viewModel = viewModel;

    UITextField *textField = [[UITextField alloc] init];
    textField.text = @" raw input ";

    [sut textFieldDidEndEditing:textField];

    XCTAssertEqualObjects(viewModel.capturedUserID, @" raw input ", @"View model should be asked to validate the raw text field value");
}

@end

The functionality of textFieldDidEndEditing: can now be verified via a single test that checks the text value from the UITextField instance is passed to the view model, which is all that remains. Everything else is handled elsewhere.

While the number of components has increased, the complexity of each component has decreased. The same is true for unit tests - the overall number has increased, but the complexity of each individual test has decreased.

Putting the Last T-Shirt Away

While these changes are worthwhile, they come with a cost - one method has turned into four types and three protocols, and there's more to hold in your head than there was before.

However, what you get in return is a structure that can grow without eventually collapsing on itself. Each responsibility sits in its own type - the validator validates, the normaliser normalises, the view model coordinates, and the view controller reacts to the user.

The newest change is no longer pinned at the bottom of the pile; you can pull it out, change it, test it, and put it back without bracing for the avalanche. The chair is a chair again.

What do you think? Let me know by getting in touch on Mastodon or Bluesky.