
When I previously wrote about better testing of view controllers on iOS I alluded briefly to the strategy of breaking the ViewController into a ViewController and a Presenter.
Again, I won’t go into mocking here, but you need a mocking framework and some understanding of what mocking is for this to make sense. Currently, I’m using OCMock. Also, XCTest is not the best documented, but here is a handy list of asserts.
This strategy means that for each ViewController there are two classes, MyViewController and MyViewPresenter. This inherits from top level classes which I have imaginatively named ViewController (inheriting from UIViewController) and Presenter (inheriting from NSObject).
ViewController and Presenter
Presenter
The aim of the Presenter class is to expose the things that any ViewController might want to access, making it unnecessary for MyViewController to know about the MyViewPresenter class.
Presenter Interface
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
@class ViewController; | |
@interface Presenter : NSObject | |
@property(nonatomic, weak) ViewController *viewController; | |
– (void)viewLoaded; | |
– (NSArray *)leftNavigationButtons; | |
– (NSArray *)rightNavigationButtons; | |
@end |
Presenter Implementation
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#import "Presenter.h" | |
@implementation Presenter | |
@synthesize viewController = viewController_; | |
– (void)viewLoaded { | |
// By default, do nothing. | |
} | |
– (NSArray *)leftNavigationButtons { | |
return @[]; | |
} | |
– (NSArray *)rightNavigationButtons { | |
return @[]; | |
} | |
@end |
ViewController
This class handles setting the presenter, ensuring the navigation buttons are set up properly, and that viewLoaded gets called.
ViewController Interface
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#import <UIKit/UIKit.h> | |
@class Presenter; | |
@interface ViewController : UIViewController | |
@property(readonly, nonatomic) Presenter *presenter; | |
// Designated initializer. | |
– (id)initWithPresenter:(Presenter *)presenter; | |
– (void)dismissViewControllerAnimated:(BOOL)animated | |
withCompletionBlock:(void (^)(void))completionBlock; | |
@end |
ViewController Implementation
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#import "ViewController.h" | |
#import "Presenter.h" | |
@implementation ViewController { | |
Presenter *presenter_; | |
} | |
@synthesize presenter = presenter_; | |
– (id)initWithPresenter:(Presenter *)presenter { | |
self = [super init]; | |
if (self) { | |
presenter_ = presenter; | |
[presenter setViewController:self]; | |
} | |
return self; | |
} | |
– (void)viewDidLoad { | |
[super viewDidLoad]; | |
[[self navigationItem] setLeftBarButtonItems:[[self presenter] leftNavigationButtons] | |
animated:YES]; | |
[[self navigationItem] setRightBarButtonItems:[[self presenter] rightNavigationButtons] | |
animated:YES]; | |
[presenter_ viewLoaded]; | |
} | |
– (void)dismissViewControllerAnimated:(BOOL)animated | |
withCompletionBlock:(void (^)(void))completionBlock { | |
[[self navigationController] dismissViewControllerAnimated:animated | |
completion:completionBlock]; | |
} | |
@end |
Testing ViewController and Presenter
Neither of these classes do very much, but they provide us with a way to create a seam which is how we write unit tests. It might seem unnecessary to write tests for these, but that just means that the tests will be quick and simple. I err on the side of if it exists, test it. Both because it’s normally faster to just test it than decide every time, and also because I am often not as smart as I’d like to think I am, therefore am liable to break things.
I’ve opted to use Strict mocks rather than their more forgiving brethren, because I want to know exactly what is going on. This makes the tests a little more brittle than strictly necessary, but I find it a helpful learning mechanism.
PresenterTest
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#import <OCMock/OCMock.h> | |
#import <XCTest/XCTest.h> | |
#import "Presenter.h" | |
#import "ViewController.h" | |
@interface PresenterTest : XCTestCase { | |
id mockViewController_; | |
Presenter *presenter_; | |
} | |
@end | |
@implementation PresenterTest | |
– (void)setUp { | |
[super setUp]; | |
mockViewController_ = OCMStrictClassMock([ViewController class]); | |
presenter_ = [[Presenter alloc] init]; | |
[presenter_ setViewController:mockViewController_]; | |
} | |
– (void)tearDown { | |
[mockViewController_ verify]; | |
[super tearDown]; | |
} | |
– (void)testViewLoaded { | |
// By default, do nothing. | |
[presenter_ viewLoaded]; | |
} | |
– (void)testLeftNavigationButtons { | |
// By default, empty. | |
XCTAssertEqual(0, [[presenter_ leftNavigationButtons] count]); | |
} | |
– (void)testRightNavigationButtons { | |
// By default, empty. | |
XCTAssertEqual(0, [[presenter_ rightNavigationButtons] count]); | |
} | |
@end |
ViewControllerTest
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#import <OCMock/OCMock.h> | |
#import <XCTest/XCTest.h> | |
#import "Presenter.h" | |
#import "ViewController.h" | |
@interface ViewControllerTest : XCTestCase { | |
id mockPresenter_; | |
ViewController *viewController_; | |
} | |
@end | |
@implementation ViewControllerTest | |
– (void)setUp { | |
[super setUp]; | |
mockPresenter_ = OCMStrictClassMock([Presenter class]); | |
OCMExpect([mockPresenter_ setViewController:OCMOCK_ANY]); | |
viewController_ = [[ViewController alloc] initWithPresenter:mockPresenter_]; | |
} | |
– (void)tearDown { | |
[mockPresenter_ verify]; | |
[super tearDown]; | |
} | |
– (void)testInit { | |
OCMVerify([mockPresenter_ setViewController:viewController_]); | |
} | |
– (void)testViewDidLoad { | |
OCMExpect([mockPresenter_ leftNavigationButtons]); | |
OCMExpect([mockPresenter_ rightNavigationButtons]); | |
OCMExpect([mockPresenter_ viewLoaded]); | |
[viewController_ viewDidLoad]; | |
OCMVerify([mockPresenter_ leftNavigationButtons]); | |
OCMVerify([mockPresenter_ rightNavigationButtons]); | |
OCMVerify([mockPresenter_ viewLoaded]); | |
} | |
– (void)testDismissViewController { | |
id mockNavigationController = OCMStrictClassMock([UINavigationController class]); | |
id mockViewController = [OCMockObject partialMockForObject:viewController_]; | |
[[[mockViewController expect] andReturn:mockNavigationController] navigationController]; | |
OCMExpect([mockNavigationController dismissViewControllerAnimated:YES completion:nil]); | |
[viewController_ dismissViewControllerAnimated:YES withCompletionBlock:nil]; | |
OCMVerify([mockNavigationController dismissViewControllerAnimated:YES completion:nil]); | |
} | |
@end |
Example: HomeViewController and HomeViewPresenter
This is the home screen for an image app, with a simple UI featuring 3 buttons – take a picture, show the gallery, and “inspire” which is not yet implemented.
HomeViewPresenter Interface
The init method is exposed for testing, but the ViewController is instantiated in the app by calling createViewController.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#import "Presenter.h" | |
@class HomeViewController; | |
@class UIImageHelper; | |
@interface HomeViewPresenter : | |
Presenter<UIImagePickerControllerDelegate, UINavigationControllerDelegate> | |
+ (HomeViewController *)createViewController; | |
// Exposed for testing only. | |
– (id)initWithImageHelper:(UIImageHelper *)imageHelper; | |
@end |
HomeViewPresenter Implementation
Notice, the view elements are accessed through the views and the actions added to them all call methods in the Presenter itself. The Presenter is also the delegate for the ImagePickerViewController.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#import "HomeViewPresenter.h" | |
#import "HomeView.h" | |
#import "HomeViewController.h" | |
#import "ImageViewController.h" | |
#import "ImageViewPresenter.h" | |
#import "UIImageHelper.h" | |
@interface HomeViewPresenter () { | |
UIImageHelper *imageHelper_; | |
} | |
@end | |
@implementation HomeViewPresenter | |
+ (HomeViewController *)createViewController { | |
HomeViewPresenter *presenter = [HomeViewPresenter new]; | |
HomeViewController *viewController = [[HomeViewController alloc] initWithPresenter:presenter]; | |
return viewController; | |
} | |
– (id)initWithImageHelper:(UIImageHelper *)imageHelper { | |
self = [super init]; | |
if (self) { | |
imageHelper_ = imageHelper; | |
} | |
return self; | |
} | |
– (void)viewLoaded { | |
HomeView *homeView = [[self homeViewController] homeView]; | |
if ([imageHelper_ isSourceTypeAvailable:UIImagePickerControllerSourceTypeCamera]) { | |
[[homeView cameraButton] addTarget:self | |
action:@selector(cameraButtonSelected:) | |
forControlEvents:UIControlEventTouchUpInside]; | |
} else { | |
[[homeView cameraButton] setEnabled:NO]; | |
} | |
[[homeView galleryButton] addTarget:self | |
action:@selector(galleryButtonSelected:) | |
forControlEvents:UIControlEventTouchUpInside]; | |
[[homeView inspireButton] addTarget:self | |
action:@selector(inspireButtonSelected:) | |
forControlEvents:UIControlEventTouchUpInside]; | |
} | |
– (HomeViewController *)homeViewController { | |
return (HomeViewController *)[self viewController]; | |
} | |
#pragma mark private | |
– (void)cameraButtonSelected:(id)sender { | |
[[self homeViewController] showImagePickerWithType:UIImagePickerControllerSourceTypeCamera | |
delegate:self | |
]; | |
} | |
– (void)galleryButtonSelected:(id)sender { | |
[[self homeViewController] showImagePickerWithType:UIImagePickerControllerSourceTypePhotoLibrary | |
delegate:self]; | |
} | |
– (void)inspireButtonSelected:(id)sender { | |
// TODO(cate): Implement. | |
NSLog(@"inspire button pressed"); | |
} | |
#pragma mark UIImagePickerControllerDelegate | |
– (void)imagePickerController:(UIImagePickerController *)picker | |
didFinishPickingMediaWithInfo:(NSDictionary *)info { | |
UIImage *image = info[UIImagePickerControllerOriginalImage]; | |
ImageViewController *imageViewController = | |
[ImageViewPresenter createViewControllerWithImage:image]; | |
[[[self viewController] navigationController] pushViewController:imageViewController | |
animated:YES]; | |
[[self viewController] dismissViewControllerAnimated:YES withCompletionBlock:nil]; | |
} | |
– (void)imagePickerControllerDidCancel:(UIImagePickerController *)picker { | |
[[self viewController] dismissViewControllerAnimated:YES withCompletionBlock:nil]; | |
} | |
@end |
HomeViewController Interface
The ViewController exposes the view within it, and a method for launching an ImagePickerViewController.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#import "ViewController.h" | |
@class HomeView; | |
@interface HomeViewController : ViewController | |
@property(nonatomic, readonly) HomeView *homeView; | |
– (void)showImagePickerWithType:(UIImagePickerControllerSourceType)type | |
delegate:(id <UINavigationControllerDelegate, UIImagePickerControllerDelegate>)delegate; | |
@end |
HomeViewController Implementation
You can see as a result the ViewController has very little code, because all it is doing is presentation.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#import "HomeViewController.h" | |
#import "HomeView.h" | |
@implementation HomeViewController | |
@synthesize homeView = homeView_; | |
-(void)viewWillAppear:(BOOL)animated { | |
[[self navigationController] setNavigationBarHidden:YES animated:YES]; | |
} | |
– (void)loadView { | |
homeView_ = [HomeView new]; | |
[self setView:homeView_]; | |
} | |
– (void)showImagePickerWithType:(UIImagePickerControllerSourceType)type | |
delegate:(id <UINavigationControllerDelegate, UIImagePickerControllerDelegate>)delegate { | |
UIImagePickerController *imagePicker = [UIImagePickerController new]; | |
[imagePicker setSourceType:type]; | |
[imagePicker setDelegate:delegate]; | |
[[self navigationController] presentViewController:imagePicker animated:YES completion:nil]; | |
} | |
@end |
Testing HomeViewController
The tests for the HomeViewController are very simple.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#import <OCMock/OCMock.h> | |
#import <XCTest/XCTest.h> | |
#import "HomeViewController.h" | |
#import "HomeViewPresenter.h" | |
@interface HomeViewControllerTest : XCTestCase { | |
id mockPresenter_; | |
HomeViewController *viewController_; | |
} | |
@end | |
@implementation HomeViewControllerTest | |
– (void)setUp { | |
[super setUp]; | |
mockPresenter_ = OCMStrictClassMock([HomeViewPresenter class]); | |
OCMExpect([mockPresenter_ setViewController:[OCMArg any]]); | |
viewController_ = [[HomeViewController alloc] initWithPresenter:mockPresenter_]; | |
} | |
– (void)testViewLoaded { | |
[viewController_ loadView]; | |
XCTAssertNotNil([viewController_ homeView]); | |
} | |
– (void)testShowImagePickerWithType { | |
id mockNavigationController = OCMStrictClassMock([UINavigationController class]); | |
id mockViewController = [OCMockObject partialMockForObject:viewController_]; | |
[[[mockViewController expect] andReturn:mockNavigationController] navigationController]; | |
OCMExpect([mockNavigationController presentViewController:[OCMArg any] | |
animated:YES | |
completion:nil]); | |
[viewController_ showImagePickerWithType:UIImagePickerControllerSourceTypePhotoLibrary | |
delegate:mockPresenter_]; | |
OCMVerify([mockNavigationController presentViewController:[OCMArg any] | |
animated:YES | |
completion:nil]); | |
} | |
@end |
Testing HomeViewPresenter
The presenter is a little more interesting. Notice how we capture the action added to the UIButton and call it using sendActionsForControlEvents:.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#import <OCMock/OCMock.h> | |
#import <XCTest/XCTest.h> | |
#import "HomeView.h" | |
#import "HomeViewController.h" | |
#import "HomeViewPresenter.h" | |
#import "UIImageHelper.h" | |
@interface HomeViewPresenterTest : XCTestCase { | |
id mockCameraButton_; | |
id mockGalleryButton_; | |
id mockHomeView_; | |
id mockImageHelper_; | |
id mockInspireButton_; | |
id mockViewController_; | |
HomeViewPresenter *presenter_; | |
} | |
@end | |
@implementation HomeViewPresenterTest | |
– (void)setUp { | |
[super setUp]; | |
mockCameraButton_ = OCMStrictClassMock([UIButton class]); | |
mockGalleryButton_ = OCMStrictClassMock([UIButton class]); | |
mockHomeView_ = OCMStrictClassMock([HomeView class]); | |
mockImageHelper_ = OCMStrictClassMock([UIImageHelper class]); | |
mockInspireButton_ = OCMStrictClassMock([UIButton class]); | |
mockViewController_ = OCMStrictClassMock([HomeViewController class]); | |
OCMStub([mockViewController_ homeView]).andReturn(mockHomeView_); | |
presenter_ = [[HomeViewPresenter alloc] initWithImageHelper:mockImageHelper_]; | |
[presenter_ setViewController:mockViewController_]; | |
} | |
– (void)tearDown { | |
OCMVerifyAll(mockCameraButton_); | |
OCMVerifyAll(mockGalleryButton_); | |
OCMVerifyAll(mockHomeView_); | |
OCMVerifyAll(mockImageHelper_); | |
OCMVerifyAll(mockInspireButton_); | |
OCMVerifyAll(mockViewController_); | |
} | |
– (void)testCreateViewController { | |
HomeViewController *viewController = [HomeViewPresenter createViewController]; | |
XCTAssertNotNil(viewController); | |
HomeViewPresenter *presenter = (HomeViewPresenter *) [viewController presenter]; | |
XCTAssertNotNil(presenter); | |
} | |
– (void)testAddTargetsToButtonsCameraButtonDisabledNoCamera { | |
OCMStub([mockHomeView_ cameraButton]).andReturn(mockCameraButton_); | |
OCMStub([mockHomeView_ galleryButton]).andReturn(mockGalleryButton_); | |
OCMStub([mockHomeView_ inspireButton]).andReturn(mockInspireButton_); | |
OCMStub([mockImageHelper_ isSourceTypeAvailable:UIImagePickerControllerSourceTypeCamera]) | |
.andReturn(NO); | |
OCMExpect([mockCameraButton_ setEnabled:NO]); | |
OCMExpect([mockGalleryButton_ addTarget:[OCMArg any] | |
action:[OCMArg anySelector] | |
forControlEvents:UIControlEventTouchUpInside]); | |
OCMExpect([mockInspireButton_ addTarget:[OCMArg any] | |
action:[OCMArg anySelector] | |
forControlEvents:UIControlEventTouchUpInside]); | |
[presenter_ viewLoaded]; | |
} | |
– (void)testAddTargetsToButtonsCameraButtonEnabledWithCamera { | |
OCMStub([mockHomeView_ cameraButton]).andReturn(mockCameraButton_); | |
OCMStub([mockHomeView_ galleryButton]).andReturn(mockGalleryButton_); | |
OCMStub([mockHomeView_ inspireButton]).andReturn(mockInspireButton_); | |
OCMStub([mockImageHelper_ isSourceTypeAvailable:UIImagePickerControllerSourceTypeCamera]) | |
.andReturn(YES); | |
OCMExpect([mockCameraButton_ addTarget:[OCMArg any] | |
action:[OCMArg anySelector] | |
forControlEvents:UIControlEventTouchUpInside]); | |
OCMExpect([mockGalleryButton_ addTarget:[OCMArg any] | |
action:[OCMArg anySelector] | |
forControlEvents:UIControlEventTouchUpInside]); | |
OCMExpect([mockInspireButton_ addTarget:[OCMArg any] | |
action:[OCMArg anySelector] | |
forControlEvents:UIControlEventTouchUpInside]); | |
[presenter_ viewLoaded]; | |
} | |
– (void)testCameraButtonSelected { | |
// Get the action added to a real button (not a mock). | |
UIButton *button = [[UIButton alloc] init]; | |
OCMStub([mockImageHelper_ isSourceTypeAvailable:UIImagePickerControllerSourceTypeCamera]) | |
.andReturn(YES); | |
OCMStub([mockHomeView_ cameraButton]).andReturn(button); | |
OCMStub([mockHomeView_ galleryButton]).andReturn(mockGalleryButton_); | |
OCMStub([mockHomeView_ inspireButton]).andReturn(mockInspireButton_); | |
OCMExpect([mockGalleryButton_ addTarget:[OCMArg any] | |
action:[OCMArg anySelector] | |
forControlEvents:UIControlEventTouchUpInside]); | |
OCMExpect([mockInspireButton_ addTarget:[OCMArg any] | |
action:[OCMArg anySelector] | |
forControlEvents:UIControlEventTouchUpInside]); | |
[presenter_ viewLoaded]; | |
// Make sure the right thing is called. | |
OCMExpect([mockViewController_ showImagePickerWithType:UIImagePickerControllerSourceTypeCamera | |
delegate:presenter_]); | |
[button sendActionsForControlEvents:UIControlEventTouchUpInside]; | |
OCMVerify([mockViewController_ showImagePickerWithType:UIImagePickerControllerSourceTypeCamera | |
delegate:presenter_]); | |
} | |
– (void)testGalleryButtonSelected { | |
// Get the action added to a real button (not a mock). | |
UIButton *button = [[UIButton alloc] init]; | |
OCMStub([mockImageHelper_ isSourceTypeAvailable:UIImagePickerControllerSourceTypeCamera]) | |
.andReturn(YES); | |
OCMStub([mockHomeView_ cameraButton]).andReturn(mockCameraButton_); | |
OCMStub([mockHomeView_ galleryButton]).andReturn(button); | |
OCMStub([mockHomeView_ inspireButton]).andReturn(mockInspireButton_); | |
OCMExpect([mockCameraButton_ addTarget:[OCMArg any] | |
action:[OCMArg anySelector] | |
forControlEvents:UIControlEventTouchUpInside]); | |
OCMExpect([mockInspireButton_ addTarget:[OCMArg any] | |
action:[OCMArg anySelector] | |
forControlEvents:UIControlEventTouchUpInside]); | |
[presenter_ viewLoaded]; | |
// Make sure the right thing is called. | |
OCMExpect | |
([mockViewController_ showImagePickerWithType:UIImagePickerControllerSourceTypePhotoLibrary | |
delegate:presenter_]); | |
[button sendActionsForControlEvents:UIControlEventTouchUpInside]; | |
OCMVerify( | |
[mockViewController_ showImagePickerWithType:UIImagePickerControllerSourceTypePhotoLibrary | |
delegate:presenter_]); | |
} | |
– (void)testInspireButtonSelected { | |
// TODO(cate): Fill this in when it does something. | |
// Get the action added to a real button (not a mock). | |
UIButton *button = [[UIButton alloc] init]; | |
OCMStub([mockImageHelper_ isSourceTypeAvailable:UIImagePickerControllerSourceTypeCamera]) | |
.andReturn(YES); | |
OCMStub([mockHomeView_ cameraButton]).andReturn(mockCameraButton_); | |
OCMStub([mockHomeView_ galleryButton]).andReturn(mockGalleryButton_); | |
OCMStub([mockHomeView_ inspireButton]).andReturn(button); | |
OCMExpect([mockCameraButton_ addTarget:[OCMArg any] | |
action:[OCMArg anySelector] | |
forControlEvents:UIControlEventTouchUpInside]); | |
OCMExpect([mockGalleryButton_ addTarget:[OCMArg any] | |
action:[OCMArg anySelector] | |
forControlEvents:UIControlEventTouchUpInside]); | |
[presenter_ viewLoaded]; | |
// Make sure the right thing is called. | |
[button sendActionsForControlEvents:UIControlEventTouchUpInside]; | |
} | |
@end |
The End
When starting from scratch, this method makes it so easy to write unit tests and doesn’t really increase the amount of code required per ViewController, just splits it in two. It’s harder to retrofit to an existing codebase, but it is possible.
Start with the top level classes, and then choose the simplest ViewControllers in your codebase to split. Add tests for them. Then choose progressively more complicated ones. You may need to add more methods to the top level ViewController and Presenter, depending on the complexity of your app. Often the reason why we don’t add tests for ViewControllers is that we never have, so starting is the hardest part.
Finally, on UIAutomation tests, I don’t see this as a replacement for KIF or other UIAutomation tests. These are great for making sure that every screen on the app loads OK, for example, and I still see apps sometimes (especially as apps have got larger) where some unloved corner of the app means that that what should launch a new screen just crashes. However these kind of tests allow us to get into details with less setup than is required by UIAutomation tests, making them easier and less time-consuming to debug.
For more detail on this and other aspects of iOS unit-testing you might find my digital workshop helpful.
3 replies on “Better Testing of View Controllers on iOS: Part 2”
More adventures in Unit Testing – Better Testing of View Controllers on iOS: Part 2 http://t.co/SsCt5WBzil
RT @catehstn: More adventures in Unit Testing – Better Testing of View Controllers on iOS: Part 2 http://t.co/SsCt5WBzil
RT @catehstn: More adventures in Unit Testing – Better Testing of View Controllers on iOS: Part 2 http://t.co/SsCt5WBzil