Test Driving UITableViews with Cedar

Joe Masilotti

April 20th, 2015

One of the first things a developer does when learning iOS development is to display a list of items to the user. In iOS we use UITableViews to show one-dimensional tables of information. In practice they look like a long list of data and should be used in that way. UITableViews get their information from a UITableViewDataSource, which responds to a few delegate methods for a number of cells and what information the cells contain.

This post will follow a step-by-step guide to test driving UITableViews in iOS. All code samples will use the behavior-driven testing framework Cedar. Cedar can be installed as a Cocoapod by adding the following to your Podfile:

target Specs do
    pod Cedar
    end

Follow this guide for installation and configuration instructions if you are having trouble or want a crash course on the framework.

Unit-Style Approach

One way to test table views is to follow a unit-style approach on the data source. The goal there is to call single public methods and assert that the correct state was altered or the return value was configured correctly. The target for unit testing a UITableView is its UITableViewDataSource property. The tests for this are fairly straightforward as they call -tableView:cellForRowAtIndexPath: and -tableView:numberOfCellsInSection: directly.

For example, let's say we want our controller to display a table with the current list of iPhones. Our mental assertions are that this table should show a single section with nine items, one for each of the iPhone, iPhone 3G, iPhone 3GS, iPhone 4, iPhone 4s, iPhone 5, iPhone 5s, iPhone 6, and iPhone 6 Plus. The unit tests will follow a very similar pattern.

Since a table defaults to one section we don't need to write a test asserting the number of sections. We can just go about testing that there are nine cells and assuming that the first and last cells text is correct, everything is working.

describe(@"ViewController", ^{
        __block ViewController *subject;

        beforeEach(^{
            subject = [[ViewController alloc] init];
        });

        describe(@"-tableView:numberOfRowsInSection:", ^{
            it(@"should have nine cells", ^{
                [subject tableView:subject.tableView numberOfRowsInSection:0] should equal(9);
            });
        });

        describe(@"-tableView:cellForRowAtIndexPath:", ^{
            __block UITableViewCell *cell;

            context(@"the first cell", ^{
                beforeEach(^{
                    NSIndexPath *indexPath = [NSIndexPath indexPathForRow:0 inSection:0];
                    cell = [subject tableView:subject.tableView cellForRowAtIndexPath:indexPath];
                });

                it(@"should display 'iPhone'", ^{
                    cell.textLabel.text should equal(@"iPhone");
                });
            });

            context(@"the last cell", ^{
                beforeEach(^{
                    NSIndexPath *indexPath = [NSIndexPath indexPathForRow:8 inSection:0];
                    cell = [subject tableView:subject.tableView cellForRowAtIndexPath:indexPath];
                });

                it(@"should display 'iPhone 6 Plus'", ^{
                    cell.textLabel.text should equal(@"iPhone 6 Plus");
                });
            });
        });
    });

Now the good part about these tests is that they are easy to follow and straight to the point. When we ask how many items there are we expect the right amount. And when we want to ensure the first cell is set up correctly we test just that.

Issues

Unfortunately there are a few problems with this approach. The biggest issue is that we can get these tests to pass without actually displaying anything on the screen. A simple implementation of these two methods in our controller will make everything green but has no guarantee that a table view is on the screen (or that one even exists!). The first step in remedying this is to write a test asserting that the table view is a subview.

Another, albeit minor, issue is we are breaking encapsulation; we are exposing that our controller conforms to the UITableViewDataSource protocol. Let's see what we can do about these two problems.

Benefits

Don't think that unit-style is bad, it just has different uses. If you have an app that uses multiple instances you will see benefits from this approach. This is because all you would need in your controller is to ensure the right type of data source was configured. You could take this one step farther by injecting the array of items to display and unit testing that. Then you have a repeatable unit of code that shows a list of data conforming to your app's specifications, which is quite powerful.

Behavior-Driven Approach

Let's take a more behavioral approach to our problem. Our goal is to display to the user the list of iPhones. If we care about what the user sees what is the closest way of replicating that? How about what cells are visible to the user?

From Apple's documentation, -visibleCells on UITableView:

Returns the table cells that are visible in the receiver.

This sounds interesting. Let's restructure our tests to run assertions on the cells that the user sees, not some made up world of delegates and data sources.

describe(@"when the view loads", ^{
            beforeEach(^{
                subject.view should_not be_nil;
                [subject.view layoutIfNeeded];
            });

            it(@"should display the first iPhone, first", ^{
                UITableViewCell *firstCell = subject.tableView.visibleCells.firstObject;
                firstCell.textLabel.text should equal(@"iPhone");
            });

            it(@"display the iPhone 6 Plus, last", ^{
                UITableViewCell *lastCell = subject.tableView.visibleCells.lastObject;
                lastCell.textLabel.text should equal(@"iPhone 6 Plus");
            });
        });

Note that in the beforeEach we assert that the view should exist. This is to kick off the controller's view lifecycle methods, namely -loadView and -viewDidLoad. We then tell its view to layout its subviews if need be. This ensures that anything we add as subviews have their layout constraints configured and applied.

To get this to pass we have a few things to take care of.

  1. Create the backing array of iPhones
  2. Create the table view and add it as a subview
  3. Become the data source and respond to the calls

The first one is easy so let's knock that out first.

@interface ViewController () <UITableViewDataSource>
    @property (nonatomic) UITableView *tableView;
    @property (nonatomic, strong) NSArray *iPhones;
    @end

    @implementation ViewController

    - (instancetype)init {
        if (self = [super init]) {
            self.iPhones = @[ @"iPhone", @"iPhone 3G", @"iPhone 3GS", @"iPhone 4", @"iPhone 4s",
                              @"iPhone 5", @"iPhone 5s", @"iPhone 6", @"iPhone 6 Plus" ];
        }
        return self;
    }

Note the opening up of the -tableView property in the interface extension. This allows us to keep it private in the header and the outside world while still being able to modify it internally.

Next let's add the table view and its auto layout constraints.

- (void)viewDidLoad {
        [super viewDidLoad];

        self.tableView = [[UITableView alloc] init];
        [self.view addSubview:self.tableView];
        [self addTableViewConstraints];
    }

    #pragma mark - Private

    - (void)addTableViewConstraints {
        self.tableView.translatesAutoresizingMaskIntoConstraints = NO;

        NSDictionary *views = @{ @"tableView": self.tableView };
        [self.view addConstraints:[NSLayoutConstraint constraintsWithVisualFormat:@"V:|[tableView]|"
                                                                          options:kNilOptions
                                                                          metrics:nil
                                                                            views:views]];
        [self.view addConstraints:[NSLayoutConstraint constraintsWithVisualFormat:@"H:|[tableView]|"
                                                                          options:kNilOptions
                                                                          metrics:nil
                                                                            views:views]];
    }

Since we aren't working with Storyboards or xibs/nibs we create the table view manually and add it as a subview. We also will need to add some simple auto layout constraints to have it fill the screen. Check out Apple's Auto Layout by Example guide if you would like a deeper explanation.

Finally let's get to the meat of the issue and respond to the data source methods.

#pragma mark - <UITableViewDataSource>

    - (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section {
        return self.iPhones.count;
    }

    - (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {
        UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:kCellIdentifier forIndexPath:indexPath];
        cell.textLabel.text = self.iPhones[indexPath.row];
        return cell;
    }

We also need to become the data source of the table so do that and register the cell in -viewDidLoad.

        [self.tableView registerClass:[UITableViewCell class] forCellReuseIdentifier:kCellIdentifier];
        self.tableView.dataSource = self;

Finally add the constant to the top of the file.

NSString * const kCellIdentifier = @"CellIdentifier";

What's interesting with this approach is that not until you have every line correct with the tests pass. This helps ensure that what is happening under spec is closer to the real experience of the app. For example, having a table view on the screen, responding to the delegate calls, but not assigning the delegate won't get you anywhere. In the unit approach you could have done just that but still seen your tests go green.

Benefits of Behavior Testing

When testing behavior you put yourself in a world that more closely represents the state when a user is interacting with it. It also enables you to test collaboration between objects without having to single very simple piece of the architecture out. This means it can be easy to get carried away and start writing full integration tests from controllers. If you keep to only testing one or two layers of abstraction, in this case the table view through the delegate, your code and specs remain easy to read and understand.

A side effect of this approach enabled us to hide some implementation details in the production code. This means we are more freely to do a green-to-green refactor without having to change our specs. For example, we could extract the UITableViewDataSource into its own object and know that it works correctly when all of the existing tests still pass. If we wanted to then reuse that collaborator we could then extract the specs and have it stand on its own. Or if our backing array turned into an NSDictionary and found everything by key nothing in our tests would have to change.

There are many styles of testing and even more ways to test Objective-C code and the Cocoa Touch framework. Behavior testing is just one approach that has proved to be the most flexible and easy to understand for me. What other techniques and methods have you implemented to ensure code coverage on your own iOS apps?

About the author

Joe Masilotti is a test-driven iOS developer living in Brooklyn, NY. He contributes to open-source testing tools on GitHub and talks about development, cooking, and craft beer on Twitter.

comments powered by Disqus