In Part 2 of this tutorial, we examined a simple app we’d like to unit test, and in which there were also some bugs lurking. In this portion of the tutorial, we will begin the process. You will find the code for this portion of the tutorial in the Version2 folder in the GitHub project.

A brief aside: For those of your that are into Test Driven Development, I’m going to point out that I’m going to be violating the tenets of that approach somewhat. In that approach, I should be writing the tests first, and then the “real” code later. This series of tutorials, however, is primarily focused on techniques for creating the tests themselves, not on teaching TDD. As a result, I’m skipping some of the “back and forth” that would normally be done in TDD. So please don’t drown me in criticism for that.

Back to our regularly scheduled program.

To start out with, I am not a fan of putting business logic in view controllers. I prefer to let view controllers be responsible for laying out items on the screen (including portrait/landscape issues) and “catching” events, but, where possible, I separate out business logic into separate classes. This “separation of concerns” makes things easier to test, and frequently more resilient to changes in the app.

The “business logic” of this app is the animation, so let’s separate that out into an SBAnimationManager class. The class’ “contract” will be that it will provide method(s) via which animations can be begun, and it will notify the controller when the animation is done. The latter can be done in at least two ways – a delegate or via a block. Let’s take the delegate approach – we’ll use blocks later in the example.

Thus, we start out with SBAnimationManager having the following interface:

@protocol SBAnimationManagerDelegate<NSObject>
- (void) animationComplete;
@end

@interface SBAnimationManager : NSObject
@property (weak, nonatomic) NSObject<SBAnimationManagerDelegate> *delegate;
- (void) bounceView:(UIView *)view to:(CGPoint) dest;
@end

Note that the call to bounceView:to: provides the animation manager with everything it needs to perform the animation. This reduces the coupling between this class and our view controller – we could theoretically use this same class with any other view controller in our app.

Moving the appropriate logic from SBViewController to SBAnimationManager makes the latter look like this:

#import "SBAnimationManager.h"
#import <AudioToolbox/AudioToolbox.h>

@implementation SBAnimationManager
{
    UIView *        _view;      // view being animated
    CGPoint         _viewHome;  // where to return view after bounce
    SystemSoundID   _soundID;   // ID for bounce sound
}

- (id) init
{
    self = [super init];
    if (self)
    {
        // load the sound
        NSString *soundFilePath = [[NSBundle bundleForClass:[self class]] pathForResource:@"bounce" ofType:@"mp3"];
        NSURL *soundFileURL = [NSURL fileURLWithPath:soundFilePath];
        OSStatus status = AudioServicesCreateSystemSoundID((__bridge CFURLRef)soundFileURL, &_soundID);
        NSAssert(status == 0, @"unexpected status: %ld", status);
    }
    return self;
}

- (void) dealloc
{
    // unload the sound
    AudioServicesDisposeSystemSoundID(_soundID);
}

- (void) bounceView:(UIView *)view to:(CGPoint)dest
{
    _view = view;
    _viewHome = view.center;
    
    [UIView animateWithDuration:2.0
                     animations:^(void)
                     {
                         _view.center = dest;
                     }
                     completion:^(BOOL finished)
                     {
                         AudioServicesPlaySystemSound(_soundID);
                         
                         [UIView animateWithDuration:2.0
                                          animations:^(void)
                                          {
                                              _view.center = _viewHome;
                                          }
                                          completion:^(BOOL finished)
                                          {
                                              [self.delegate animationComplete];
                                          }];
                     }];
}
@end

Note that even though we are using ARC, it’s OK in this case to have a dealloc method, because we need to release the sound. We just don’t try to call [super dealloc] as we would have in a non-ARC app.

SBViewController.h now looks like this:

#import "SBAnimationManager.h"

@interface SBViewController : UIViewController<SBAnimationManagerDelegate>
@property (weak, nonatomic) IBOutlet UIImageView *ballImageView;
@property (weak, nonatomic) IBOutlet UIButton *verticalButton;
@property (weak, nonatomic) IBOutlet UIButton *horizontalButton;

@property (strong, nonatomic) SBAnimationManager *animationManager;

- (IBAction)onVerticalButtonPressed:(id)sender;
- (IBAction)onHorizontalButtonPressed:(id)sender;
@end

and SBViewController.m now looks like this:

#import "SBViewController.h"

@implementation SBViewController
{
    CGPoint         _ballHome;  // ball's home position
}

- (id) init
{
    self = [super initWithNibName:@"SBViewController" bundle:[NSBundle bundleForClass:[self class]]];
    if (self)
    {
        self.animationManager = [[SBAnimationManager alloc] init];
        self.animationManager.delegate = self;
    }
    return self;
}

- (void) viewDidLoad
{
    [super viewDidLoad];
    
    _ballHome = self.ballImageView.center;
}

- (void) viewDidUnload
{
    [super viewDidUnload];
}

// start bounce toward bottom
- (IBAction)onVerticalButtonPressed:(id)sender
{
    // Destination is bottom edge of screen minus radius of ball
    CGSize ballSize = self.ballImageView.bounds.size;
    CGSize viewSize = self.view.bounds.size;
    CGPoint dest = CGPointMake(_ballHome.x, viewSize.height - ballSize.height / 2);
    [self bounceBallTo:dest];
}

// start bounce toward top.  
- (IBAction)onHorizontalButtonPressed:(id)sender
{
    // Destination is right edge of screen minus radius of ball
    CGSize ballSize = self.ballImageView.bounds.size;
    CGSize viewSize = self.view.bounds.size;
    CGPoint dest = CGPointMake(viewSize.width - ballSize.width / 2, _ballHome.y);
    [self bounceBallTo:dest];
}

- (void) bounceBallTo:(CGPoint) dest
{
    self.verticalButton.hidden = YES;
    self.horizontalButton.hidden = YES;
    
    [self.animationManager bounceView:self.ballImageView to:dest];
}

- (void) animationComplete
{
    self.verticalButton.hidden = NO;
    self.horizontalButton.hidden = NO;
}
@end

The init routine initializes the view controller and also sets up the animation manager. Note that I have deliberately exposed the animation manager as a property, since this will make it easier to test the interaction between the view controller and the animation manager. If I really don’t want to expose this, I could always move this property to a category which the main class and unit tests could include, but that’s something for another time.

At this point, we are in a position to test the “contract” between SBViewController and SBAnimationManager. When the vertical or horizontal button is pressed, SBViewController will:

  1. Hide the buttons
  2. Call bounceView:to: on the animation manager.
  3. Wait for a call to the delegate method indicating the animation is done and then re-enable the buttons.

That is a very straightforward set of interactions to test. In order to do this, we’re going to use the OCMock library. Thus, we download the library, and copy it into our project in the ThirdParty/OCMock-2.0.1 folder, and then add it to our test target. See the instructions here.

First, we delete the iOS_Unit_TestingTests.h and iOS_Unit_TestingTests.m files that Xcode created. These simply contain a single failing test, and are there so that there is at least one file in the test target. In its place, we create a SBViewControllerTests.m file that will contain tests for the SBViewController class. I use single-file unit tests – there is hardly ever a reason to have a separate .h file.

For our first test, let us make sure that pressing the vertical button does actually call the animation manager. So, we start off with a test file that looks like this:

#import <SenTestingKit/SenTestingKit.h>
#import <OCMock.h>
#import "SBViewController.h"

@interface SBViewControllerTests : SenTestCase
@end

@implementation SBViewControllerTests
{
    SBViewController *  _objUnderTest;
}

- (void) setUp
{
    _objUnderTest = [[SBViewController alloc] init];
}

- (void) tearDown
{
    _objUnderTest = nil;
}

- (void) test_onVerticalButtonPressed_callsAnimationManager
{
    id mockAnimationManager = [OCMockObject mockForClass:[SBAnimationManager class]];
    _objUnderTest.animationManager = mockAnimationManager;
    
    CGPoint dest = CGPointMake(30,430);
    [[mockAnimationManager expect] bounceView:_objUnderTest.ballImageView to:dest];
    
    [_objUnderTest onVerticalButtonPressed:_objUnderTest.verticalButton];
    
    [mockAnimationManager verify];
}
@end

setUp and tearDown take care of giving us a fresh object each time we start a test. This is important, because you don’t want “leftovers” from one test leaking into the next test, since you don’t know the order in which the tests will be run. Lines 25-26 set up a mock version of the SBAnimationManager class. On lines 28-29, we define what we expect to see on that mock object. On line 31 we execute the operation, and on line 33 we verify that our expectations were met.

If you run this test as-is, however, it fails, complaining that an unexpected method was invoked. With OCMock, this usually means that the method was called with a different set of parameters than was “expected.” If you insert a breakpoint and step into the test, you will probably find that the value was (30,548) instead of (30,430). Ah – the window isn’t sized the way we expected. Turns out that it’s set up for the iPhone 5 size instead of the iPhone 4 size. Our test should have been more explicit. So, we can add a method to our test that explicitly sets the size of the window:

- (void) sizeViewForiPhone
{
    _objUnderTest.view.frame = CGRectMake(0, 0, 320, 460);
}

and call that at the beginning of our test. Now the test passes. Our app will probably be run on an iPhone 5, however, so we should separately test that the ball is bounced to the correct vertical position on that device:

- (void) sizeViewForiPhone5
{
    _objUnderTest.view.frame = CGRectMake(0, 0, 320, 548);
}

- (void) test_onVerticalButtonPressed_callsAnimationManager_iPhone5
{
    [self sizeViewForiPhone5];
    
    id mockAnimationManager = [OCMockObject mockForClass:[SBAnimationManager class]];
    _objUnderTest.animationManager = mockAnimationManager;
    
    CGPoint dest = CGPointMake(30,518);
    [[mockAnimationManager expect] bounceView:_objUnderTest.ballImageView to:dest];
    
    [_objUnderTest onVerticalButtonPressed:_objUnderTest.verticalButton];
    
    [mockAnimationManager verify];
}

These tests might be a little fragile, since they depend on the size of the ball. Thus, we’d probably be better off in computing the positions, rather than hard-coding numbers. This way, if our ball gets larger or smaller, this particular set of tests wouldn’t break.

We also want to test that when the vertical button is pressed, the buttons are hidden. We could have combined this into the previous tests, but it’s typically better to test just one thing per test method. That way, if we accidentally break things, it’s easier to figure out what test assumption we’ve violated. Thus, we add the test

- (void) test_onVerticalButtonPressed_hidesButtons
{
    id mockAnimationManager = [OCMockObject niceMockForClass:[SBAnimationManager class]];
    _objUnderTest.animationManager = mockAnimationManager;
    
    [_objUnderTest onVerticalButtonPressed:_objUnderTest.verticalButton];
    
    STAssertTrue(_objUnderTest.verticalButton.hidden, nil);
    STAssertTrue(_objUnderTest.horizontalButton.hidden, nil);
}

Note line 3. This test really doesn’t care about the animation manager, and we don’t really want the real one trying to muck about with the screen. Thus, again we replace it with a mock object. If we just used a standard mock, however, the object would get upset when it started getting method calls without “expectations.” What we really want is a “stub”, which will accept the calls, but do nothing. This is what, by default, niceMockForClass: creates. Technically, we could have just set the animation manager to nil, relying on the fact that Objective C is smart enough to handle method calls on nil objects, but this is a useful technique to remember for situations in which that won’t work.

So now we’ve tested the first two portions of our expected behavior (at least for the vertical case). What about the third? The view controller is supposed to re-display the buttons when the animation is complete. Here, we don’t have to actually involve the animation manager at all – granted, it is the animation manager that will normally be calling animationComplete, but we can just as easily test it directly:

- (void) test_animationComplete_showsButtons
{
    _objUnderTest.verticalButton.hidden = YES;
    _objUnderTest.horizontalButton.hidden = YES;
    
    [_objUnderTest animationComplete];
    
    STAssertFalse(_objUnderTest.verticalButton.hidden, nil);
    STAssertFalse(_objUnderTest.horizontalButton.hidden, nil);
}

This is one of the great advantages of the delegate pattern – it provides nice boundaries at which it is easy to insert tests. Any time your code implements a delegate interface, you can test that portion of the interface by pretending to be the object that calls that method.

We also need to repeat the tests we performed on the vertical button with similar tests on the horizontal button. Setting up the mock each time is a lot of repeated code, so we’ll also refactor out some of that. At the end of this, our test file looks like this:

#import <SenTestingKit/SenTestingKit.h>
#import <OCMock.h>
#import "SBViewController.h"

@interface SBViewControllerTests : SenTestCase
@end

@implementation SBViewControllerTests
{
    SBViewController *  _objUnderTest;
    id                  _mockAnimationManager;
}

- (void) setUp
{
    _objUnderTest = [[SBViewController alloc] init];
}

- (void) tearDown
{
    _objUnderTest = nil;
}

- (void) installMockAnimationManager
{
    _mockAnimationManager = [OCMockObject mockForClass:[SBAnimationManager class]];
    _objUnderTest.animationManager = _mockAnimationManager;
}

- (void) installNiceMockAnimationManager
{
    _mockAnimationManager = [OCMockObject niceMockForClass:[SBAnimationManager class]];
    _objUnderTest.animationManager = _mockAnimationManager;
}

- (void) sizeViewForiPhone
{
    _objUnderTest.view.frame = CGRectMake(0, 0, 320, 460);
}

- (void) sizeViewForiPhone5
{
    _objUnderTest.view.frame = CGRectMake(0, 0, 320, 548);
}

- (CGPoint) topRightCornerOfView
{
    CGRect viewFrame = _objUnderTest.view.frame;
    CGPoint topRight = CGPointMake(viewFrame.origin.x + viewFrame.size.width, 0);
    return topRight;
}

- (CGPoint) bottomLeftCornerOfView
{
    CGRect viewFrame = _objUnderTest.view.frame;
    CGPoint bottomLeft = CGPointMake(0, viewFrame.origin.y + viewFrame.size.height);
    return bottomLeft;
}

- (CGPoint) ballBottomLeftPosition
{
    CGSize ballSize = _objUnderTest.ballImageView.frame.size;
    CGPoint bottomLeft = [self bottomLeftCornerOfView];
    
    return CGPointMake(bottomLeft.x + ballSize.width / 2,
                       bottomLeft.y - ballSize.height / 2);
}

- (CGPoint) ballTopRightPosition
{
    CGSize ballSize = _objUnderTest.ballImageView.frame.size;
    CGPoint topRight = [self topRightCornerOfView];
    
    return CGPointMake(topRight.x - ballSize.width / 2, ballSize.height / 2);
}

- (void) test_onVerticalButtonPressed_callsAnimationManager_iPhone
{
    [self sizeViewForiPhone];
    [self installMockAnimationManager];
    
    CGPoint dest = [self ballBottomLeftPosition];
    [[_mockAnimationManager expect] bounceView:_objUnderTest.ballImageView to:dest];
    
    [_objUnderTest onVerticalButtonPressed:_objUnderTest.verticalButton];
    
    [_mockAnimationManager verify];
}

- (void) test_onVerticalButtonPressed_callsAnimationManager_iPhone5
{
    [self sizeViewForiPhone5];
    [self installMockAnimationManager];
    
    CGPoint dest = [self ballBottomLeftPosition];
    [[_mockAnimationManager expect] bounceView:_objUnderTest.ballImageView to:dest];
    
    [_objUnderTest onVerticalButtonPressed:_objUnderTest.verticalButton];
    
    [_mockAnimationManager verify];
}

- (void) test_onVerticalButtonPressed_hidesButtons
{
    [self installNiceMockAnimationManager];
    
    [_objUnderTest onVerticalButtonPressed:_objUnderTest.verticalButton];
    
    STAssertTrue(_objUnderTest.verticalButton.hidden, nil);
    STAssertTrue(_objUnderTest.horizontalButton.hidden, nil);
}

- (void) test_onHorizontalButtonPressed_callsAnimationManager_iPhone
{
    [self sizeViewForiPhone];
    [self installMockAnimationManager];
    
    CGPoint dest = [self ballTopRightPosition];
    [[_mockAnimationManager expect] bounceView:_objUnderTest.ballImageView to:dest];
    
    [_objUnderTest onHorizontalButtonPressed:_objUnderTest.horizontalButton];
    
    [_mockAnimationManager verify];
}

- (void) test_onHorizontalButtonPressed_callsAnimationManager_iPhone5
{
    [self sizeViewForiPhone5];
    [self installMockAnimationManager];
    
    CGPoint dest = [self ballTopRightPosition];
    [[_mockAnimationManager expect] bounceView:_objUnderTest.ballImageView to:dest];
    
    [_objUnderTest onHorizontalButtonPressed:_objUnderTest.horizontalButton];
    
    [_mockAnimationManager verify];
}

- (void) test_onHorizontalButtonPressed_hidesButtons
{
    [self installNiceMockAnimationManager];
    
    [_objUnderTest onHorizontalButtonPressed:_objUnderTest.horizontalButton];
    
    STAssertTrue(_objUnderTest.verticalButton.hidden, nil);
    STAssertTrue(_objUnderTest.horizontalButton.hidden, nil);
}

- (void) test_animationComplete_showsButtons
{
    _objUnderTest.verticalButton.hidden = YES;
    _objUnderTest.horizontalButton.hidden = YES;
    
    [_objUnderTest animationComplete];
    
    STAssertFalse(_objUnderTest.verticalButton.hidden, nil);
    STAssertFalse(_objUnderTest.horizontalButton.hidden, nil);
}
@end

Thus, we’ve tested that pressing either of the buttons hides the buttons and begins an animation to the correct point, and that when the animation manager reports that the animation is complete the buttons are re-shown. Since that’s pretty much all the controller does, our controller is now well-tested, and we can move on to the animation manager.

Before we do that, however, it would be instructive to see how well our tests have actually exercised our code. Thus, in Part 4 of this tutorial we’ll add code coverage to the testing process.