As of Part 5 of this tutorial we have basic unit tests in place, but our original code is too monolithic to be able to easily test. Thus, we’re now going to restructure the SBAnimationManager class to make it more testable and flexible. The source code for this tutorial may be found on GitHub. This part’s code is in the Version5 directory.

Our existing animation manager class does all its work with a big nested set of blocks. What we really want to be able to do is to break this up into separate operations so that we can verify each individual step in the overall animation. Thus, we’re going to restructure the animation manager so it works as follows:

  1. Each individual move on the screen will be a separate operation.
  2. The SBAnimationManager class will maintain a list of the operations that it is to perform.
  3. When one operation is complete, it will step to the next one in the list. When the list is complete, the overall sequence is done.

It should be obvious that this approach is going to be a lot more flexible – we’ll be able to implement both our two-segment bounces and the “four corners bounce” that marketing wanted us to add way back in Part 2 of this tutorial.

Thus, we introduce the SBAnimationSegment class, which tracks the destination for an animation segment, and whether or not to play the bounce sound at the end:

@interface SBAnimationSegment : NSObject
@property (readonly, nonatomic) CGPoint destCenter;
@property (readonly, nonatomic) BOOL playSoundAtEnd;
- (id) initWithPoint:(CGPoint) destCenter playSoundAtEnd:(BOOL) playSoundAtEnd;
@end
@implementation SBAnimationSegment
@synthesize destCenter = _destCenter;
@synthesize playSoundAtEnd = _playSoundAtEnd;

- (id) initWithPoint:(CGPoint) destCenter playSoundAtEnd:(BOOL) playSoundAtEnd
{
    self = [super init];
    if (self)
    {
        _destCenter = destCenter;
        _playSoundAtEnd = playSoundAtEnd;
    }
    return self;
}

@end

This then lets us implement a nice, simple API on SBAnimationManager in terms of animating arrays of segments:

- (CGPoint) homeInParent:(UIView *)view
{
    CGSize viewSize = view.bounds.size;
    return CGPointMake(viewSize.width / 2, viewSize.height / 2);
}

- (CGPoint) lowerLeftInParent:(UIView *)view
{
    CGSize parentSize = view.superview.bounds.size;
    CGSize viewSize = view.bounds.size;
    return CGPointMake(viewSize.width / 2, parentSize.height - viewSize.height / 2);
}

- (CGPoint) lowerRightInParent:(UIView *)view
{
    CGSize parentSize = view.superview.bounds.size;
    CGSize viewSize = view.bounds.size;
    return CGPointMake(parentSize.width - viewSize.width / 2, parentSize.height - viewSize.height / 2);
}

- (CGPoint) upperRightInParent:(UIView *)view
{
    CGSize parentSize = view.superview.bounds.size;
    CGSize viewSize = view.bounds.size;
    return CGPointMake(parentSize.width - viewSize.width / 2, viewSize.height / 2);
}

- (void) verticalBounce:(UIView *)view
{
    self.viewBeingAnimated = view;
    
    SBAnimationSegment *seg1 = [[SBAnimationSegment alloc] initWithPoint:[self lowerLeftInParent:view] playSoundAtEnd:YES];
    SBAnimationSegment *seg2 = [[SBAnimationSegment alloc] initWithPoint:[self homeInParent:view] playSoundAtEnd:NO];
    
    [self beginAnimations:@[seg1, seg2]];
}

- (void) horizontalBounce:(UIView *)view
{
    self.viewBeingAnimated = view;
    
    SBAnimationSegment *seg1 = [[SBAnimationSegment alloc] initWithPoint:[self upperRightInParent:view] playSoundAtEnd:YES];
    SBAnimationSegment *seg2 = [[SBAnimationSegment alloc] initWithPoint:[self homeInParent:view] playSoundAtEnd:NO];
    
    [self beginAnimations:@[seg1, seg2]];
}

- (void) fourCornerBounce:(UIView *)view
{
    self.viewBeingAnimated = view;
    
    SBAnimationSegment *seg1 = [[SBAnimationSegment alloc] initWithPoint:[self lowerLeftInParent:view] playSoundAtEnd:YES];
    SBAnimationSegment *seg2 = [[SBAnimationSegment alloc] initWithPoint:[self lowerRightInParent:view] playSoundAtEnd:YES];
    SBAnimationSegment *seg3 = [[SBAnimationSegment alloc] initWithPoint:[self upperRightInParent:view] playSoundAtEnd:YES];
    SBAnimationSegment *seg4 = [[SBAnimationSegment alloc] initWithPoint:[self homeInParent:view] playSoundAtEnd:NO];
    
    [self beginAnimations:@[seg1, seg2, seg3, seg4]];
}

(Changing the animation manager’s external API obviously requires changes to the view controller and to its unit tests. We’re going to skip over that in this post, however the code on GitHub has been updated accordingly.)

Our animation manager will then work as follows:

  1. beginAnimations: will save the array of animations, and will start the first one.
  2. When one animation ends, we will see whether it was successful or not. Remember that the iOS animation engine will tell us if an animation did not complete because, for example, the user left the app.
  3. If the animation is successful, and if there’s another one to do, start that animation.
  4. Otherwise, we’re done, and can call our delegate.

Skipping a lot of what we’d do with TDD, the implementation I came up with is as follows:

- (void) beginAnimations:(NSArray *) segments
{
    self.animationSegments = segments;
    self.currentSegmentIndex = 0;
    [self beginCurrentAnimationSegment];
}

- (void) beginCurrentAnimationSegment
{
    SBAnimationSegment *currentAnimationSegment = self.animationSegments[self.currentSegmentIndex];
    
    [UIView animateWithDuration:_duration
                     animations:^(void)
                     {
                         self.viewBeingAnimated.center = currentAnimationSegment.destCenter;
                     }
                     completion:^(BOOL finished)
                     {
                         [self currentAnimationSegmentEnded:finished];
                     }];
}

- (void) currentAnimationSegmentEnded:(BOOL) complete
{
    SBAnimationSegment *currentAnimationSegment = self.animationSegments[self.currentSegmentIndex];
    if (complete)
    {
        if (currentAnimationSegment.playSoundAtEnd)
        {
            [self playBounceSound];
        }
        
        if (self.currentSegmentIndex + 1 < [self.animationSegments count])
        {
            self.currentSegmentIndex = self.currentSegmentIndex + 1;
            [self beginCurrentAnimationSegment];
        }
        else
        {
            [self.delegate animationComplete];
        }
    }
    else
    {
        self.viewBeingAnimated.center = [self homeInParent:self.viewBeingAnimated];
        [self.delegate animationComplete];
    }
}

I have deliberately separated the internal API into three parts:

  1. What do we do to get going?
  2. What do we do at the beginning of an animation segment?
  3. What do we do at the end of an animation segment?

The answers, of course, are:

  1. Save the array, arrange for the first segment in the array to be the active segment, and start that segment.
  2. Use the UIView methods to move the view being animated to its destination, and arrange a callback when the operation is complete.
  3. If we didn’t finish, return the view home and tell the delegate we’re done. Otherwise, start the next segment. If we’re done, call the delegate.

The point is that each of these individual operations is nicely tested in isolation, so we can rigorously exercise the individual method, making sure that they all adhere both to the external API (e.g. adjusting the view, calling the delegate) but also to the internal API (e.g. making sure that the correct next step in the sequence is performed.)

In addition, I have deliberately made animationSegments and currentSegmentIndex properties since I will want access to them during testing, but have declared them in SBAnimationManager+Internal.h so that they are not really “public.”

So, let’s begin with verticalBounce:. What is it supposed to do? Set up a two-segment array, where the first segment is to the lower left corner and the second is the home position, then start the animations with that. As we discussed in the previous part of the tutorial, if we want to call one method and then verify that another method is called, we can use a partial mock. In this case, however, we not only want to make sure that the method is called, but that it is called with the correct values. There are two ways that we can do this. We could set up the “expectation” with the values that we expect to be passed in. Each expect invocation remembers the values that are passed to it. Thus, we could do:

    NSArray *expectedSegments = . . .
    [[wrapper expect] beginAnimation:expectedSegments];

If the wrong values are passed in, the expectation will not be satisfied, and we will get a test failure. This works, but has the issue that the testing software needs to be able to compare the SBAnimationSegment instances to see if they match. This would require us to code an equalTo: method for this class. Perfectly doable, however we don’t need that code for production. Let’s attack it another way.

One of the things you can do with an OCMock stub or an expectation is to provide an alternate implementation using andCall:onObject:. Thus, we can set the expectation up to allow any value to be passed in, and then redirect the call to a method on our test class that will save the value. Once the value has been saved, we can then validate it any way we wish. Thus, we can set up a test of the verticalBounce: method as follows:

@implementation SBAnimationManagerTests
{
    SBAnimationManager *    _objUnderTest;
    id                      _mockDelegate;
    UIView *                _parentView;
    UIView *                _viewBeingAnimated;
    NSArray *               _savedSegments;
}

. . .

- (void) saveAnimationSegments:(NSArray *) segments
{
    _savedSegments = segments;
}

- (void) test_verticalBounce_startsAnimationToLowerLeftCorner
{
    CGSize parentSize = _parentView.frame.size;
    CGPoint originalViewCenter = _viewBeingAnimated.center;
    CGPoint lowerLeftCenter = CGPointMake(_viewBeingAnimated.bounds.size.width / 2,
                                          parentSize.height - _viewBeingAnimated.bounds.size.height / 2);
    
    id wrapper = [OCMockObject partialMockForObject:_objUnderTest];
    [[[wrapper expect] andCall:@selector(saveAnimationSegments:) onObject:self] beginAnimations:OCMOCK_ANY];
    
    [wrapper verticalBounce:_viewBeingAnimated];
    
    [_mockDelegate verify];
    [wrapper verify];
    
    STAssertEquals([_savedSegments count], (unsigned int)2, nil);
    
    SBAnimationSegment *seg1 = _savedSegments[0];
    STAssertEqual(seg1.destCenter.x, lowerLeftCenter.x, nil);
    STAssertEqual(seg1.destCenter.y, lowerLeftCenter.y, nil);
    STAssertTrue(seg1.playSoundAtEnd, nil);
    
    SBAnimationSegment *seg2 = _savedSegments[1];
    STAssertEqual(seg2.destCenter.x, originalViewCenter.x, nil);
    STAssertEqual(seg2.destCenter.y, originalViewCenter.y, nil);
    STAssertFalse(seg2.playSoundAtEnd, nil);
}

On line 7 we’ve added a member to the test class to save the segments, and on lines 12-15, a method that will store them there. Our expectation on line 25 has been modified to say “expect a call to beginAnimations:, but with any arguments – don’t verify them. When that call is made, call saveAnimationSegments: on self.” Implicit in this is that whatever arguments were passed to beginAnimations: will be passed through to our intercepting method. Thus, once we have called the method under test, we can then inspect what was passed to beginAnimations: and validate the contents. Note also that we don’t need to call waitForAnimations here, because we never actually get to the point of animating. We can add similar tests for horizontalBounce: and fourCornerBounce:.

Note that simply setting up an expectation and then trying to read out the values using the animationSegments property won’t work – the values stored in the property are set in the method we’re “expecting,” and when we set an “expect” on a partial mock, it doesn’t call through to the underlying object. Of course, we could have pointed the andCall:onObject: at the underlying object, but that would have then caused the animations to start – we were trying to “short circuit” that and simply check that they would have been started if we hadn’t intervened.

beginAnimations: is supposed to save the animation segments, reset the current animation index to zero and start the first animation.

- (void) test_beginAnimations_setsUpAndStarts
{
    SBAnimationSegment *seg1 = [[SBAnimationSegment alloc] initWithPoint:CGPointMake(10,10) playSoundAtEnd:YES];
    SBAnimationSegment *seg2 = [[SBAnimationSegment alloc] initWithPoint:CGPointMake(0,0) playSoundAtEnd:NO];
    
    _objUnderTest.currentSegmentIndex = 99; // make sure it gets reset

    id wrapper = [OCMockObject partialMockForObject:_objUnderTest];
    [[wrapper expect] beginCurrentAnimationSegment];
    
    [wrapper beginAnimations:@[seg1,seg2]];
    
    [wrapper verify];
    STAssertEquals([wrapper currentSegmentIndex], (unsigned int)0, nil);
    
    NSArray *segments = [wrapper animationSegments];
    
    STAssertEquals([segments count], (unsigned int) 2, nil);
    STAssertEquals(segments[0], seg1, nil);
    STAssertEquals(segments[1], seg2, nil);
}

Note that we have to call getters and setters instead of using property syntax. wrapper is an id, and Objective C doesn’t know that it supports the properties.

beginCurrentAnimationSegment is responsible for starting the animation, and making sure that our “after animation” method is called. Thus, we would expect to see the view’s center set and, after the animation has had a chance to run, the callback to our currentAnimationSegmentEnded: method.


- (void) test_beginCurrentAnimationSegment_animatesAndCallsback
{
    SBAnimationSegment *seg1 = [[SBAnimationSegment alloc] initWithPoint:CGPointMake(10,20) playSoundAtEnd:YES];
    _objUnderTest.animationSegments = @[seg1];
    _objUnderTest.viewBeingAnimated = _viewBeingAnimated;
    
    id wrapper = [OCMockObject partialMockForObject:_objUnderTest];
    [[wrapper expect] currentAnimationSegmentEnded:YES];

    [wrapper beginCurrentAnimationSegment];
    
    [self waitForAnimations];
    
    [wrapper verify];
    
    CGPoint viewCenter = _viewBeingAnimated.center;
    STAssertEquals(viewCenter.x, 10.0F, nil);
    STAssertEquals(viewCenter.y, 20.0F, nil);
}

Oops! When we run this we end up with a nasty exception:

2013-03-08 16:37:25.293 iOS Unit Testing[91737:11903] *** Terminating app due to uncaught exception 'NSInternalInconsistencyException', reason: 'Ended up in subclass forwarder for SBAnimationManager-0x75c2190-384471445.269383 with unstubbed method currentAnimationSegmentEnded:'

“Ended up in subclass forwarder” is OCMock’s cryptic way of saying “you set up an expectation in a partial mock, but when the method was called, the expectation wasn’t met, so I started to call through to the wrapped method, which isn’t legal.”

If we put a breakpoint in beginCurrentAnimationSegment, what we’ll see is that the completion block in the UIView animation is being called with a value of NO for finished, and our expectation was set up to expect a value of YES. If we change line 8 to expect a NO, then everything works.

This is one of the quirks of testing animations in an “off-screen” manner – from the operating system’s point of view, the animations are never treated as successfully finishing. I presume that this is because the views we are using are not, in fact, attached to the real GUI. In any event, it’s one of the things of which we need to be aware. It’s also the reason that I deliberately made currentAnimationSegmentEnded: a method, rather than putting that logic in the completion block. If I had done the latter, there wouldn’t have been a way to test the “success” path. By exposing currentAnimationSegmentEnded: as a method, however, I can simulate both successful and unsuccessful completions, along with any other situations I want.

In the next part of this tutorial, we’ll finish up testing the animation manager, and will look at exactly what happens when an animation gets interrupted by the user switching to a different application.