For many years, iOS applications didn’t get the same kind of unit testing attention that other applications do. Part of this was due to the fact that Apple’s tools didn’t make it easy to set up and run unit tests, and part of it was “it’s all UI, and UI is hard to unit test.” Fortunately, Apple has fixed the first, and the second isn’t really true if you structure your code properly. In this series of posts, I’m going to include some tips and tricks I recommend for doing unit testing in iOS apps.
Use the Correct Bundle
One of the “gotchas” in doing unit testing is that when the unit tests are running, the “main bundle” for your application is actually the unit test bundle, not the application bundle. This means that code like this:
UIViewController *controller = [[MyViewController alloc] initWithNibName:@"MyViewController.xib" bundle:nil];
or this:
UIViewController *controller = [[MyViewController alloc] initWithNibName:@"MyViewController.xib" bundle:[NSBundle mainBundle];
doesn’t work. When your unit test is running, either of these will look for the nib file in the unit test bundle, rather than the application bundle. Instead, use bundleForClass
to force iOS to look in the bundle that contained the class:
UIViewController *controller = [[MyViewController alloc] initWithNibName:@"MyViewController.xib" bundle:[NSBundle bundleForClass:[MyViewController class]];
My own preference is to wrap this up in the view controller’s init
method. I prefer this for two reasons:
- The name of the nib, or the fact the the view controller is using a nib, is implementation information in my mind. Users of the class shouldn’t have to know about that.
- Using
initWithNibName:bundle:
requires scattering the name of the nib file around your code. DRY (Don’t Repeat Yourself). Code it in just one place.
Thus, I usually code an init
method for my view controllers that looks like this:
- (id) init { self = [super initWithNibName:@"nibName.xib" bundle:[NSBundle bundleForClass:[self class]]]; if (self) { ... do any variable inits I need } return self }
Inject Dependencies
If you code in other languages or environments, you may be familiar with the term Dependency Injection, which is a form of Inversion of Control. This can be used very effectively in Objective C as well. Rather than having your code create related objects on the fly, provide them to the object as part of its creation. The great advantage of this is that it allows you to inject mock implementations during unit testing.
If you want to use a full-fledged framework for Dependency Injection, I can recommend Objection. This is a very flexible and easy-to-use framework for dependency injection. If you don’t want to go this far, you can still use the general injection technique by using a factory method for the major classes. The factory method can create the object and inject all the required other objects (using their factory methods as required). Your unit test can then, if necessary, replace implementations with mock implementations before running tests. The key is that the related object is provided through a setter, rather than being constructed internally.
Let’s take an example. Suppose MyViewController
is going to access a remote service. I have encapsulated the remote service access logic in a class named RemoteService
. The non-DI method would be to do this:
- (void) onRemoteServiceActivated:(id)sender { _service = [[RemoteService alloc] init]; _service.delegate = self; [_service doSomething]; }
The problem, of course, is that when it’s time to unit test MyViewController
, it starts trying to do “real” service accesses, which is probably a bad idea for a unit test. Instead, we could do the following:
@property (string, nonatomic) RemoteService *service; ... - (void) onRemoteServiceActivated:(id)sender { self.service.delegate = self; [self.service doSomething]; }
The view controller expects that it has been provided a service object, and simply uses it. If we were using Objection, we would then simply add:
objection_requires(@"service")
to the implementation, and Objection would automatically inject an instance of RemoteService
as part of creating our controller. If we preferred the factory method:
+ (MyViewController *) factory { MyViewController *obj = [[MyViewController alloc] init]; obj.service = [RemoteService factory]; return obj; }
The factory
method returns a fully-initialized, ready-to-use instance of the object. In the case of the RemoteService
, if the service object was supposed to be a singleton, its own factory
method can handle that.
Now when it’s time to test MyViewController
, we can obtain fully-initialized instances of it using its factory
method, but we have the ability to replace any of the dependent objects, if necessary, before executing individual tests. Thus, we could do:
- (void) test_onRemoteServiceActivated_invokesService { MyViewController *objUnderTest = [MyViewController factory]; MockRemoteService *mockRemoteService = [[MockRemoteService alloc] init]; objUnderTest.service = mockRemoteService; [objUnderTest onRemoteServiceActivated:objUnderTest.activateButton]; STAssertTrue(mockRemoteService.doSomethingWasCalled, nil); }
(This assumes MockRemoteService
was a class we wrote. In practice, I would probably implement this using a mocking framework like OCMock, but that will be the subject of another post.)
Separate Concerns
Note that it’s a LOT easier to test your user interface (specifically, your view controllers) if you limit their responsibilities to actually dealing with on-screen matters. In our example above, it might well have been possible to code the details of the remote service access into methods of the view controller itself – establishing connections, handling callbacks, etc. This makes it much more difficult to test things, however, since all the various functions get muddled together. Thus, by separating the UI aspects (responding to button presses, updating labels, etc.) from the non-UI aspects (accessing the remote service), it becomes much easier to individually test each part.
Thus, if it isn’t directly related to the UI, code probably belongs in a non-UI class. If operations in the non-UI class will end up affecting the UI (such as after something is retrieved from the remote service), this is the perfect place to introduce a delegate pattern – essentially defining a callback sequence from the RemoteService
class back to the UI. This becomes another place into which you can insinuate your unit tests, since the unit tests can directly manipulate the delegate interface to make sure that any possible response from the remote service is handled properly by the UI. This is a heck of a lot easier than trying to actually simulate all the error legs during live user testing.
It Doesn’t Actually Have To Go On The Screen
Don’t underestimate the amount of testing you can do on a UI class without every actually putting the view up on the screen – the vast majority of your view controller logic will work just fine with views that are “dangling in space.” This is particularly useful when testing universal applications. Even if certain views are intended for the iPhone and others for the iPad, you can generally create those views on either simulator, thus allowing you to run your full set of unit tests on either emulator. One exception is if you’re using classes such as UISplitViewController
that don’t exist on the iPhone. In this case, you can:
- Restrict yourself to just running the unit tests on the iPad simulator
- Modify the tests that only can run on the iPad to exit early if they are being run on an iPhone simulator.
To do the latter, include something like this in unit tests that use iPad-specific classes:
- (BOOL) oniPhoneSimulator { return [[UIDevice currentDevice] userInterfaceIdiom] == UIUserInterfaceIdiomPhone; }
and then just skip any test that depends on iPad classes via:
- (void) testIPadSpecificController { if ([self oniPhoneSimulator]) return; ... remainder of test }
In Part 2 of this tutorial we will start working our way through unit testing a sample app that involves screen animations and sound in order to illustrate various techniques for writing unit tests.