Co Author: Andy Pliszka
In Test Driven iPhone Development with Cedar, Part I, we created a new Xcode project called Recipes and set it up to use Cedar for test-driven development. In this post, we’ll test-drive our first piece of actual functionality. All code is posted to GitHub with tags for each major breakpoint so you can pick up at any part in the series.
Principles of test-driven development
We could write (and people have written) whole books on what test-driven development really means, but to keep it simple, we’re going to stick to the core idea: We must not write any application code until a failing test motivates us to do so.
The Story
As a reminder, here’s the story we’re working on:
As a user,
I should be able to see a list of recipes.
so I can cook a delicious dinner
Scenario: Users looks at the recipe list
Given I have the recipe app installed
When I open the recipe app
Then I should see a list of recipes
Where to begin? The story says that when I open the app, I should see a list of recipes. Right now when we open the app, we see a list, but there’s nothing in it. That’s because right now, we’re setting the rootViewController
to be a generic UITableViewController
, which doesn’t do what we need.
So we’ll have to create a subclass of UITableViewController
to manage the recipes list; we’ll call it BDDRecipesViewController
. But before we actually create this new class, we’ll modify the BDDAppDelegateSpec
to reflect our new understanding:
#import "BDDAppDelegate.h"
#import "BDDRecipesViewController.h"
using namespace Cedar::Matchers;
using namespace Cedar::Doubles;
SPEC_BEGIN(BDDAppDelegateSpec)
describe(@"BDDAppDelegate", ^{
__block BDDAppDelegate *delegate;
beforeEach(^{
delegate = [[[BDDAppDelegate alloc] init] autorelease];
});
context(@"when the app is finished loading", ^{
beforeEach(^{
[delegate application:nil didFinishLaunchingWithOptions:nil];
});
it(@"should display recipes", ^{
delegate.window.rootViewController should be_instance_of([BDDRecipesViewController class]);
});
});
});
SPEC_END
When we try to run the tests (⌘-U), the build fails to compile, with this error:
'BDDRecipesViewController.h' file not found
We treat build failures like test failures, i.e. as guides to tell us how to move forward with our code. In this case, the build failure tells us that we need to create a BDDRecipesViewController
class.
Create BDDRecipesViewController
Create a subclass of UITableViewController
called BDDRecipesViewController
. Run the tests; the build succeeds this time, but the test fails with this error:
FAILURE BDDAppDelegate when the app is finished loading should display recipes
/Users/pivotal/workspace/Recipes/Specs/BDDAppDelegateSpec.mm:23 Expected < (UITableViewController)> to be an instance of class <BDDRecipesViewController>
In other words, even though we’ve created BDDRecipesViewController
, we haven’t actually set it to be the root view controller of the window. Let’s do that now. In BDDAppDelegate.m
, create an instance of BDDRecipesViewController
and set it to be the window
‘s rootViewController
. Don’t forget to #import
the header file:
#import "BDDAppDelegate.h"
#import "BDDRecipesViewController.h"
@implementation BDDAppDelegate
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions
{
self.window = [[UIWindow alloc] initWithFrame:[[UIScreen mainScreen] bounds]];
self.window.rootViewController = [[BDDRecipesViewController alloc] init];
self.window.backgroundColor = [UIColor whiteColor];
[self.window makeKeyAndVisible];
return YES;
}
Run the tests. The build succeeds and all the tests pass—in the language of TDD, we’ve “gone green”. Now it’s time to add the tests we need for the list of recipes.
Test-driving the number of sections
As you may know, UITableView
s have a dataSource
which they call to get the information they need to display the table onscreen, and typically, the UITableView’s view controller is that dataSource
. So BDDRecipesViewController
needs to implement the required methods in the UITableViewDataSource
protocol, which are:
-numberOfSectionsInTableView:
-tableView:numberOfRowsForSection:
-tableView:cellForRowAtIndexPath:
Of course, before we actually write any BDDRecipesViewController
code, we need to create a spec file to hold the tests that will describe its behavior:
Create BDDRecipesViewControllerSpec.mm
:
Remember to add it to the Specs target:
#import "BDDRecipesViewController.h"
using namespace Cedar::Matchers;
using namespace Cedar::Doubles;
SPEC_BEGIN(BDDRecipesViewControllerSpec)
describe(@"BDDRecipesViewController", ^{
__block BDDRecipesViewController *viewController;
beforeEach(^{
});
});
SPEC_END
Now that we have a spec file, we can start writing the tests that will drive out the data source behavior we need. We’ll start with -numberOfSectionsInTableView
. We know that we’ll only need one section, so we’ll assert that -numberOfSectionsInTableView:
returns 1
.
#import "BDDRecipesViewController.h"
using namespace Cedar::Matchers;
using namespace Cedar::Doubles;
SPEC_BEGIN(BDDRecipesViewControllerSpec)
describe(@"BDDRecipesViewController", ^{
__block BDDRecipesViewController *viewController;
describe(@"-numberOfSectionsInTableView:", ^{ // 1
it(@"should return 1", ^{ // 2
viewController = [[BDDRecipesViewController alloc] init]; // 3
viewController.view should_not be_nil; // 4
[viewController numberOfSectionsInTableView:viewController.tableView] should equal(1); // 5
[viewController release];
});
});
});
SPEC_END
What do these lines mean?
- Add describe section for tests of
-numberOfSectionsInTableView:
- Add a unit test (which is a block that holds assertions), and write a description of what that assertion is testing; in this case, that
-numberOfSectionsInTableView:
returns1
. - Create an instance of
viewController
, which we’ll use to run the assertion we’re about to write. - This line may seem mysterious. Of course the
viewController
‘s view should not be nil, but why write an assertion to test it? We need to write this becauseUIViewControllers
don’t actually create their views until the first timeview
is called, and so if we don’t callview
onviewController
, there won’t be atableView
to test on the next line. - Here’s the heart of this test: When we call
-numberOfSectionInTableView:
withviewController
‘stableView
, it should return1
.
Run the tests; as expected, we get a failure:
FAILURE BDDRecipesViewController -numberOfSectionsInTableView: should return 1
/Users/pivotal/workspace/Recipes/Specs/BDDRecipesViewControllerSpec.mm:17 Expected <0> to equal <1>
So now we just have to implement -numberOfSections
in BDDRecipesViewController.m
:
- (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView
{
return 1;
}
Run the tests; now they all pass.
Test-driving the number of items
Now we need to test-drive -tableView:numberOfRowsInSection:
. Add this describe
block below the describe
block for -numberOfSectionsInTableView:
:
describe(@"-numberOfSectionsInTableView:", ^{
it(@"should return 1", ^{
viewController = [[BDDRecipesViewController alloc] init];
viewController.view should_not be_nil;
[viewController numberOfSectionsInTableView:viewController.tableView] should equal(1);
[viewController release];
});
});
describe(@"-tableView:numberOfRowsInSection:", ^{
it(@"should return the number of rows for the table view", ^{
viewController = [[BDDRecipesViewController alloc] init];
viewController.view should_not be_nil;
[viewController tableView:viewController.tableView numberOfRowsInSection:0] should equal(4);
[viewController release];
});
});
Run the tests and they fail with the following error:
FAILURE BDDRecipesViewController -tableView:numberOfRowsInSection: should return the number of rows for the table view
/Users/pivotal/workspace/Recipes/Specs/BDDRecipesViewControllerSpec.mm:27 Expected <0> to equal <4>
Fixture data
Where did 4 come from? When writing tests, we don’t actually want to test against real data, because real data is unpredictable; it can and probably will change over time, and that can lead to brittle tests. Instead, we’ll create some fake data to use for testing purposes. Add this line to test:
describe(@"-tableView:numberOfRowsInSection:", ^{
it(@"should return the number of rows for the table view", ^{
viewController = [[BDDRecipesViewController alloc] init];
viewController.recipes = @[@"Hamburgers", @"Guacamole", @"Bigos", @"Spaghetti"];
viewController.view should_not be_nil;
[viewController tableView:viewController.tableView numberOfRowsInSection:0] should equal(4);
[viewController release];
});
});
recipes
is our fake data, and it has four elements; when we run the test, we expect that -tableView:numberOfRowsInSection:
should return 4.
But we can’t yet run the tests, because the build fails. This is because viewController
doesn’t have a recipes
property. Indeed, BDDRecipesViewController
has no model object. Did we overlook this very basic principle of MVC architecture? No, we simply waited for the tests to tell us we needed a model—and now they have. Add a property recipes
to BDDRecipesViewController.h
:
@interface BDDRecipesViewController : UITableViewController
@property (nonatomic, copy) NSArray *recipes;
@end
Now our tests build, but they still fail:
FAILURE BDDRecipesViewController -tableView:numberOfRowsInSection: should return the number of rows for the table view
/Users/pivotal/workspace/Recipes/Specs/BDDRecipesViewControllerSpec.mm:31 Expected <0> to equal <4>
That’s fine; it just means we’re ready now to actually implement -tableView:numberOfRowsInSection:
in BDDRecipesViewController.m
:
- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section
{
return self.recipes.count;
}
Run the tests; they all pass.
At this point, you may have noticed that we’ve duplicated some code in the tests, specifically the creation of viewController
. We can refactor our tests to avoid this repetition by using a beforeEach
block. We’ll also pull the recipes
into the beforeEach
so that we can use them again in the tests that follow. We can use afterEach
to clean up the tests after they’ve run. Refactor the tests so that they look like this:
#import "BDDRecipesViewController.h"
using namespace Cedar::Matchers;
using namespace Cedar::Doubles;
SPEC_BEGIN(BDDRecipesViewControllerSpec)
describe(@"BDDRecipesViewController", ^{
__block BDDRecipesViewController *viewController;
__block NSArray *recipes;
beforeEach(^{
viewController = [[BDDRecipesViewController alloc] init];
recipes = @[@"Hamburgers", @"Guacamole", @"Bigos", @"Spaghetti"];
});
afterEach(^{
[viewController release];
});
describe(@"-numberOfSectionsInTableView:", ^{
it(@"should return 1", ^{
viewController.view should_not be_nil;
[viewController numberOfSectionsInTableView:viewController.tableView] should equal(1);
});
});
describe(@"-tableView:numberOfRowsInSection:", ^{
it(@"should return the number of rows for the table view", ^{
viewController.view should_not be_nil;
viewController.recipes = recipes;
[viewController tableView:viewController.tableView numberOfRowsInSection:0] should equal(4);
});
});
});
SPEC_END
Test-driving the creation of cells and display of data
The last method we need to implement is -tableView:cellForRowAtIndexPath:
. To test this, we want to know that the table view cells returned by the method contain the correct content. Add this describe block below the previous ones:
describe(@"-tableView:cellForRowAtIndexPath", ^{
it(@"should return a table view cell with the right label", ^{
viewController.view should_not be_nil;
viewController.recipes = recipes;
NSIndexPath *indexPath = [NSIndexPath indexPathForItem:0 inSection:0];
UITableViewCell *cell = [viewController tableView:viewController.tableView cellForRowAtIndexPath:indexPath];
cell.textLabel.text should equal(@"Hamburgers");
});
});
Run the tests, and we get this failure:
FAILURE BDDRecipesViewController -tableView:cellForRowAtIndexPath should return a table view cell with the right label
/Users/pivotal/workspace/Recipes/Specs/BDDRecipesViewControllerSpec.mm:44 Expected <(null)> to equal <Hamburgers>
So now we just have to implement the method:
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath
{
UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:@"UITableViewCell" forIndexPath:indexPath];
cell.textLabel.text = self.recipes[indexPath.row];
return cell;
}
When we run the tests, they don’t all pass, but instead of a failure, we actually see an exception instead:
EXCEPTION BDDRecipesViewController -tableView:cellForRowAtIndexPath should return a table view cell with the right label
unable to dequeue a cell with identifier UITableViewCell - must register a nib or a class for the identifier or connect a prototype cell in a storyboard
As you may know, in iOS 6 Apple introduced an improved way of loading table view cells. Instead of explicitly calling alloc
and init
to create a new cell, we can simply call -dequeueReusableCellWithIdentifier:forIndexPath:
, and if a new cell needs to be created, the table view will create it for us. In order for that to work, though, we need to tell the table view what type of table view cell to use for each cell identifier, and we do this by registering the UITableViewCell
class in -viewDidLoad
:
- (void)viewDidLoad {
[self.tableView registerClass:[UITableViewCell class] forCellReuseIdentifier:@"UITableViewCell"];
}
Add this code, run the tests, and they all pass.
Conclusion
We’ve successfully test-driven our table view controller to display a list of recipes! Of course, when you run the actual app, you still don’t see any recipes appear, and that’s because we don’t have any actual recipe data. In the next post, we’ll examine how to test-drive the model classes for Recipes so that our users can create real recipe data.
Remember that the core principle behind TDD is that we don’t write any app code until some test failure motivates us to do so. By test-driving our feature, we ensure that we’re writing correct code at all times; of course, this requires that the tests adequately cover the functionality we expect from the app, and indeed that’s one of the challenges of TDD. Well-written tests not only ensure correct code, however, they also act as a sort of self-documentation for the code. Reading the describe
and it
blocks, it’s really easy to get a good understanding of what the code is supposed to do. And because we’ve written good tests, we can forge ahead with confidence, knowing that as long as our tests pass, any new code we write won’t break our existing code.