Adoption Curve Dot Net

Unit-testing iOS User Interfaces Without Tears

| Comments

Testing user interfaces can be hard, mainly because UIs are designed to be driven by users and not code. There are various more-or-less hacky solutions for this that usually involve building some kind of robot arm to poke the UI in response to a script. The problem is that these solutions tend to be slow, and usually require a fair amount of hoop-jumping to get going.

Testing iOS user interfaces isn’t any different. There are tools like Frank and Calabash that will drive the UI with a kind of headless browser that will be familiar if you’ve done any kind of behaviour-driven Rails development (the Cucumber, Rspec and WebRat/Selenium/Capybara etc approach).

But these headless approaches suffer from all the same problems. Tests can be slow to run, the testing environment can be tricky to set up, and you need to step away from the Objective-C environment and start wrestling with Ruby and regular expressions to write the tests.

An alternative approach is to cheat slightly, and test the UI from the inside.

This builds on the model-view-controller model – in an idealised iOS app, the UI that the user touches is just a thin veneer that does two things. It displays information to the outside world through controls like UILabels; and receives user interactions through controls like UIButtons. It’s dumb (or should be dumb) – the label contents are displayed exactly as the controller passes them into the view; and the touches are handed straight back up the responder chain for the controller to deal with.

So testing from the UI perspective involves two aspects – that display controls such as labels are displaying what they should be; and that touches trigger the correct results. Fortunately, both of these can be tested to a reasonable extent without needing to employ headless frameworks. Although it’s not completely fool-proof, it goes a long way to making sure that your user interfaces are backed up with decent tests.

Here’s a hypothetical situation, testing a minimal user interface by way of an example. We’ve got a UIViewController subclass with an interface that consists of a button and a label. Tapping the button changes the content of the label, and I want to test this. We don’t want to have to install and configure Calabash or Frank – so how can this be done?

We’re going to split the testing into two parts – testing that the UI is correctly wired up; and then that the interaction methods do what they should. This set of tests is written using the Kiwi framework, but the approach will be similar using SenTest or another test library.

Testing the UI connections

The process of testing the UI connections involve creating an instance of the UI inside your test classes; and then verifying that everything is connected as it should be. In this case, I’m testing:

  • that there’s should be a UILabel present, and it should initially show I'm waiting...
  • that there should be a UIButton present, and that should be wired up to the updateGreeting: method

Here are the tests for checking the UILabel:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
describe(@"The view controller", ^{

        context(@"when instantiated", ^{

            __block TestViewController *vc = nil;

            beforeEach(^{
                vc = [[TestViewController alloc] initWithNibName:@"TestViewController" bundle:nil];
                [vc view];
            });

            it(@"should have been instantiated correctly", ^{
                [vc shouldNotBeNil];
            });

            context(@"should have a label that", ^{

                it(@"exists and is called called greetingLabel", ^{
                    [[vc.greetingLabel should] beKindOfClass:[UILabel class]];
                });

                it(@"shows 'I'm waiting' as the default content of the greetingLabel", ^{
                    [[vc.greetingLabel.text should] equal:@"I'm waiting..."];
                });

            });

            afterEach(^{
                vc = nil;
            });

        });

    });

Each test should be largely self-explanatory with the aid of the it description. The first few lines create an instance of the TestViewController as a block variable, before each individual test is run. Then having created said instance, it accesses the view controller’s view property, which forces the nib to be loaded and the controls to be instantiated and connected.

That’s a subtle – but important – point. Because we’re testing the presence of controls in the UI, we need to make sure the UI has been created (albeit that it’s done in the abstract while the tests run.)

Next, I’m testing that the view controller has actually been instantiated correctly – this doesn’t really add any value beyond the point where the first ‘real’ test passes, but it’s a useful check initially to make sure that the nib is being loaded correctly.

The first ‘real’ test checks that there is a UILabel property called greetingLabel. If for any reason the UILabel control is missing from the nib, or it hasn’t been connected to the corresponding IBOutlet, then that property will be nil and the test will fail. Similarly, it will fail if for some reason the control isn’t of the right type.

Having checked that the control exists, the next test makes sure that the default value is set up correctly. In this case, it’s ensuring that I'm waiting... will be displayed in the UI when it first loads.

Testing the button

The tests for the button are quite similar:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
describe(@"The view controller", ^{

        context(@"when instantiated", ^{

            __block TestViewController *vc = nil;

            beforeEach(^{
                vc = [[TestViewController alloc] initWithNibName:@"TestViewController" bundle:nil];
                [vc view];
            });

            it(@"should have been instantiated correctly", ^{
                [vc shouldNotBeNil];
            });

            //
            //  Label tests here
            //

            context(@"should have a button that", ^{

                it(@"exists and is called greetingButton", ^{
                    [[vc.greetingButton should] beKindOfClass:[UIButton class]];
                });

                it(@"has a target of the view controller and an action of updateGreeting:", ^{
                    NSArray *actions = [vc.greetingButton actionsForTarget:vc forControlEvent:UIControlEventTouchUpInside];
                    [actions shouldNotBeNil];
                    [[theValue([actions indexOfObject:@"updateGreeting:"]) shouldNot] equal:theValue(NSNotFound)];
                });

            });

        });

    });

The same process of instantiating the view controller gets underway, then there’s a test for the existance of the UIButton.

The next test grabs the actions for the view controller from the button as an NSArray. The name of each action will be present in the array as an NSString, so the test is simply a case of checking that the method name exists in the array. If the button hasn’t been connected to an action, this method name won’t exist and the test will fail.

Testing the methods

Now we can test the methods themselves:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
describe(@"The view controller", ^{

    context(@"when instantiated", ^{

        __block TestViewController *vc = nil;

        beforeEach(^{
            vc = [[TestViewController alloc] initWithNibName:@"TestViewController" bundle:nil];
            [vc view];
        });

        it(@"should have been instantiated correctly", ^{
            [vc shouldNotBeNil];
        });

        //
        // Label tests here
        //

        //
        // Button tests here
        //

        context(@"should have methods that", ^{

            it(@"responds to updateGreeting", ^{
                [[vc should] respondToSelector:@selector(updateGreeting:)];
            });

            it(@"updates the greeting label to 'Hello, World!' when the updateGreeting: method fires", ^{
                [vc updateGreeting:vc.greetingButton];
                [[vc.greetingLabel.text should] equal:@"Hello, World!"];
            });

        });

    });

});

Again, the same view controller instantiation process takes place. Then the methods are tested – firstly, ensuring that the view controller declares the updateGreeting: method.

Then, it’s time to test that the method actually does what it’s supposed to. We call the method with [vc updateGreeting:vc.greetingButton] and test that the greetingLabel’s text property has been updated to Hello, World!.

Assuming all the tests go green, we’ve checked that the interface is wired up correctly, and that the appropriate methods operate correctly when they’re called. No need for a headless framework, but a suite of tests that can be repeatedly run as the project progresses.

Summary

This approach isn’t completely comprehensive – testing gestures would be tricky, for example – but it does go a long way towards automating the testing of user interfaces. This can pay off in two ways:

  • it reduces the risk of problems as a result of dumb misconfiguration mistakes – not connecting a UIButton to an action, for example. Those problems can be difficult to track down, assuming you bump into them in the first place.

  • it allows you to test tricky conditional logic that’s driven by the UI – for example, toggling controls on and off in response to user actions. Testing this kind of functionality manually is tedious and error-prone – by running the tests programatically, it means they’re repeatable and easy to add edge-cases to.

Adding a headless browser approach into the mix will mean you’ve got as much test coverage as it’s realistically feasible to get – but this approach can get you a long way towards that nirvana with a lot less configuration hassle.

Comments