Better Testing of View Controllers on iOS: Part 2

bunny in a bowl
Credit: Flickr / jpockele

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

@class ViewController;
@interface Presenter : NSObject
@property(nonatomic, weak) ViewController *viewController;
– (void)viewLoaded;
– (NSArray *)leftNavigationButtons;
– (NSArray *)rightNavigationButtons;
@end

view raw
Presenter.h
hosted with ❤ by GitHub

Presenter Implementation

#import "Presenter.h"
@implementation Presenter
@synthesize viewController = viewController_;
– (void)viewLoaded {
// By default, do nothing.
}
– (NSArray *)leftNavigationButtons {
return @[];
}
– (NSArray *)rightNavigationButtons {
return @[];
}
@end

view raw
Presenter.m
hosted with ❤ by GitHub

ViewController

This class handles setting the presenter, ensuring the navigation buttons are set up properly, and that viewLoaded gets called.

ViewController Interface

#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

view raw
ViewController.h
hosted with ❤ by GitHub

ViewController Implementation

#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

view raw
ViewController.m
hosted with ❤ by GitHub

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

#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

view raw
PresenterTest.m
hosted with ❤ by GitHub

ViewControllerTest

#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

view raw
ViewControllerTest.m
hosted with ❤ by GitHub

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.

#import "Presenter.h"
@class HomeViewController;
@class UIImageHelper;
@interface HomeViewPresenter :
Presenter<UIImagePickerControllerDelegate, UINavigationControllerDelegate>
+ (HomeViewController *)createViewController;
// Exposed for testing only.
– (id)initWithImageHelper:(UIImageHelper *)imageHelper;
@end

view raw
HomeViewPresenter.h
hosted with ❤ by GitHub

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.

#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

view raw
HomeViewPresenter.m
hosted with ❤ by GitHub

HomeViewController Interface

The ViewController exposes the view within it, and a method for launching an ImagePickerViewController.

#import "ViewController.h"
@class HomeView;
@interface HomeViewController : ViewController
@property(nonatomic, readonly) HomeView *homeView;
– (void)showImagePickerWithType:(UIImagePickerControllerSourceType)type
delegate:(id <UINavigationControllerDelegate, UIImagePickerControllerDelegate>)delegate;
@end

view raw
HomeViewController.h
hosted with ❤ by GitHub

HomeViewController Implementation

You can see as a result the ViewController has very little code, because all it is doing is presentation.

#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

view raw
HomeViewController.m
hosted with ❤ by GitHub

Testing HomeViewController

The tests for the HomeViewController are very simple.

#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:.

#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 thoughts on “Better Testing of View Controllers on iOS: Part 2

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.