Thursday, September 15, 2011

Unit Tests IV

This series discusses some approaches to unit testing HelloWorld. In this post, we describe how to maintain test independence. It concludes the Unit Test series.


Test Independence
In Unit Tests III, we implemented an Application Test for HelloWorld. However, there is still one glaring problem with testButtonClickUpdatesLabel -- it does not clean up after itself. Look again:
- (void)testButtonClickUpdatesLabel
{
// Get reference to root viewcontroller.
id yourApplicationDelegate = [[UIApplication sharedApplication] delegate];
UIViewController *rootViewController = [[yourApplicationDelegate window] rootViewController];
// Downcast to specific type.
HelloWorldViewController *hwViewController = (HelloWorldViewController *)rootViewController;
// Simulate user entering name in text field.
NSString * const name = @"Ebirah";
UITextField *textField = [hwViewController textField];
[textField setText:name];

// Simulate button click (quick & dirty).
[hwViewController changeGreeting:nil];
// Now check the label value.
UILabel *label = [hwViewController label];
NSString *labelValue = [label text];
NSString *expectedValue = [NSString stringWithFormat:@"Hello, %@!", name];
STAssertEqualObjects(labelValue, expectedValue, nil);
}
Subsequent tests will start with the text field and label already set to @"Ebirah" and @"Hello, Ebirah!", respectively. Testing is most effective when test cases are independent of one another. Otherwise, results from one test can contaminate the others.


To illustrate, let's add another test. Consider the question posed at the end of the Hello World post: What happens if you leave the name field empty? The answer is, the app should display @"Hello, World!". Let's write a test to confirm this:


- (void)testEmptyNameUsesWorld
{

// Get reference to root viewcontroller.
id yourApplicationDelegate = [[UIApplication sharedApplication] delegate];
UIViewController *rootViewController = [[yourApplicationDelegate window] rootViewController];

// Downcast to specific type.
HelloWorldViewController *hwViewController = (HelloWorldViewController *)rootViewController;
// Skip entering name.
// Simulate button click (quick & dirty).
[hwViewController changeGreeting:nil];
// Now check the label value.
UILabel *label = [hwViewController label];
NSString *labelValue = [label text];
NSString *expectedValue = @"Hello, World!";
STAssertEqualObjects(labelValue, expectedValue, nil);

}
Now we have two tests, both of which assume the UI begins in a clean state, and neither of which clean up after themselves. Build and run the tests again. Notice one will fail:
testEmptyNameUsesWorld (...) failed: 'Hello, Ebirah!' should be equal to 'Hello, World!'
testEmptyNameUsesWorld fails because the assumption of a clean UI has been violated -- by testButtonClickUpdatesLabel. In other words, the second test fails because it was contaminated by the first. The text field, which was assumed to be empty, actually still contains @"Hello, Ebirah!".


One way to fix this is to add some cleanup code to testButtonClickUpdatesLabel:
- (void)testButtonClickUpdatesLabel
{
...
// Clear textfield.
[textField setText:@""];


// Clear label.
[label setText:@""];
}
This would seem to solve the problem for the HelloWorld example. Indeed, paste it in as illustrated and the tests will pass.


Unfortunately, this solution is sub-optimal. First, it is brittle because it depends upon the order of test execution. It assumes testEmptyNameUsesWorld comes after testButtonClickUpdatesLabel, which may not always be the case. Second, as more tests are implemented, this code is likely to be duplicated. Duplicated code increases maintenance.


A better solution would be to initialize the UI to a clean state prior to running each test, and encapsulate all the "initialize" logic in a single method. Even better would be if we had some way to do this automatically. Fortunately, the SenTestingKit framework (part of OCUnit, and baked into Xcode 4) answers this challenge: the setUp method. When implemented, the logic in this method is automatically invoked prior to each test, as its name suggests. (In addition, the SenTestingKit framework also recognizes the tearDown method, which is automatically invoked after each test.)


For HelloWorld, the following setUp implementation should suffice:


- (void)setUp
{
[super setUp];
// Get reference to root viewcontroller.
id yourApplicationDelegate = [[UIApplication sharedApplication] delegate];
UIViewController *rootViewController = [[yourApplicationDelegate window] rootViewController];
// Downcast to specific type.
HelloWorldViewController *hwViewController = (HelloWorldViewController *)rootViewController;
// Clear textfield.
UITextField *textField = [hwViewController textField];
[textField setText:@""];
// Clear label.
UILabel *label = [hwViewController label];
[label setText:@""];
}
Build and run the tests. Confirm they both now pass.


Final refactoring
One final point could be made about duplicated logic in the HelloWorldAppTests. If you notice, all three methods (setUp and the two test cases) use the same code to obtain their reference to the HelloWorldViewController:
// Get reference to root viewcontroller.
id yourApplicationDelegate = [[UIApplication sharedApplication] delegate];
UIViewController *rootViewController = [[yourApplicationDelegate window] rootViewController];

// Downcast to specific type.
HelloWorldViewController *hwViewController = (HelloWorldViewController *)rootViewController;
A better solution is to encapsulate this logic in its own method which all other methods can share. For example:
- (HelloWorldViewController *)getHelloWorldViewController
{
// Get reference to root viewcontroller.
id yourApplicationDelegate = [[UIApplication sharedApplication] delegate];
UIViewController *rootViewController = [[yourApplicationDelegate window] rootViewController];


// Downcast to specific type.
return (HelloWorldViewController *)rootViewController;
}
Now, all methods that need to can simply use the following code snippet:
HelloWorldViewController *hwViewController = [self getHelloWorldViewController];
Make sure the getHelloWorldViewController definition appears prior to all methods that invoke it, otherwise you will get build errors.