2011年12月6日星期二

Introduction to Test Driven Development (TDD) for iOS apps (Part 1 of 4)

Introduction to Test Driven Development (TDD) for iOS apps (Part 1 of 4):

Introduction to Test Driven Development (TDD) for iOS apps (Part 1 of 4)


After a recent developer meetup, we were talking about useful things to do at future meetings. Someone fairly new to iOS development mentioned he would love to watch someone go through the design and development of an app. He knew he could pick up many of the technical details himself, but he wanted a better understanding of the thought process and design decisions behind the development.


As with any process that requires insight and creativity, that process and those decisions vary a great deal from person to person. Even if a particular process or development approach does not fit your style, you can still learn and improve by understanding different approaches.


Test Driven Development (TDD)


TDD is an approach to development that uses automated software tests to drive the design and development of an application iteratively. It pushes the developer to focus on one thing at a time, and usually one fairly small thing at that — but without killing the forest while tending to individual trees.


One of the central practices of TDD is to write a test for some chunk of code before we write any code. The reasoning is that if we correctly express all of our expectations through our tests, when our tests pass, we know our application does what we want. So the success of our application is highly dependent not just on understanding the problem, but on how well we capture that understanding in our tests.


We will explore TDD in this tutorial by developing a simple iPhone app that plays Tic-Tac-Toe. Because our focus is on TDD, we will spend most of our time learning how to think in a “test first” manner.


Is TDD Really Worth The Effort?


There are good developers out there that think TDD is not a useful approach so we need to try and separate the arguments against writing unit tests vs doing testing the TDD way.


The arguments against unit testing in general are things like:


  • I don’t have time to write double the amount of code,
  • As the number of tests increase, they become too unwieldy to maintain,
  • Changing the API breaks too many tests, so I become hesitant to make needed changes or end up disabling tests.

I won’t debate these other than to say that there is a minimal level of testing that I view as a requirement for professional work. It’s not that I always live up to my own standards, but I am not delivering the quality work I should be as a professional when I don’t have a reasonable amount of automated tests.


There are more nuanced arguments against TDD, where we write tests first instead of later. Things like:


  • There is not a good return on the time spent writing tests for every new bit of functionality,
  • Keeping such as narrow focus can lead to a local optimization, but poor overall design,
  • It is too easy to get lost in the details and lose sight of the big picture.

I am not a TDD purist. I won’t argue that TDD is always the best way for every project, but even if you take a hybrid approach, I believe you will still find great value. Using tests as a mechanism to force myself to stop and think about how the proposed code will be used is very helpful. Writing a good test usually requires me to think at just the right level of detail.


As far as getting lost in the weeds, I have not found that to be any bigger danger than using any other method. In fact, I can be led down a false trail or a tangent more often if I do not have the specification that the test provides. Using TDD does not mean you never step back and evaluate the overall design — in fact, I encourage you to do so at various points along the way. You need those review points to make sure you are staying true to the overall purpose and goals of the application.


I recently finished a short iOS project as a subcontractor where I followed standard TDD techniques fairly closely. I wrote a blog entry summarizing my experience that may help you as you think through using TDD.


Wherever you fall in this discussion, I encourage you to spend enough time exploring TDD to make an informed decision. Even if you conclude TDD techniques are not for you, I hope you still benefit by spending some time thinking differently about app development.


Getting Started




You will learn the most from this tutorial by following the steps and doing the work yourself, but if you get stuck, see the note at the end of the tutorial to see how to jump to specific points in the tutorial.1


We will be using GHUnit for our testing framework, and later will use OCMock in our tests as well. Since the point of this tutorial is to learn about TDD, not configure Xcode, I have provided an empty Xcode 4 project already configured with two targets: an empty view based app, and a GHUnit app test target configured with OCMock support and a sample test case. There are other frameworks available that can help in our testing, but we will keep our project dependencies as simple as possible for this tutorial.


If you would like to help in setting up your own Xcode 4 project to use GHUnit, check out the tutorial “Unit Testing in Xcode 4 Quick Start Guide”.


Once you download and unzip the project file, open it in Xcode and run the TDD_TicTacToe target in the simulator. It does not do much at all, but should look like this:


TDD TicTacToe 1


Next, run the TDD_TicTacToeTests target in the simulator, it should look like this:


TDD TicTacToe 2


To see how GHUnit runs tests and ensure that GHUnit and OCMock are setup correctly, I included four test cases in the sample test class. By default two pass, and the other two fail. To run the tests, click the “Run” button on the top right and the resulting screen should look like this:


TDD TicTacToe 3


Congrats! We are ready to begin our TDD journey.


TDD Process


TDD grew out of the work of people like Kent Beck, Ward Cunningham, Martin Fowler, and Ron Jeffries as eXtreme Programming (XP) came into being in the 80′s and 90′s. Even though there is not an official, globally sanctioned definition of TDD — the best descriptions still come from Ward Cunningham’s c2 site.


Following TDD does not magically remove the need to understand the problem we are addressing, and so before writing even a single test, we need to take care of a few things. We need to:


  • Decide on the purpose of the app;
  • Determine what constitutes “Done”;
  • Do just enough up front design to get started.

TDD is an iterative process where each iteration has a clear task to accomplish — an iteration is complete when all existing test cases pass. An individual iteration can be focused on adding some new behavior or improving the existing design, but it is better not to try to do too much at once. By limiting the scope, if any of our tests break during that iteration, we can quickly find the cause.


It is helpful to keep an active task list to record observations as we find new things to be done, or changes to be made. This will help us stay focused on the current task.


Most iterations will be to add new behavior, and look something like this:


  • Based on our current understanding and task list, decide on what behavior we want to add next;
  • Think about a good way to test it;
  • Write a small test that uses that behavior and clearly expresses our expectation of the results;
  • Write just enough code that will let the test compile, but still fails because our expectations were not met;
  • Run the test and watch it fail so we know we the test is being exercised;
  • Write or modify just enough code to make the new test pass;
  • Ensure all existing tests still pass.

When we are tempted to follow a rabbit trail during this process, the best choice is to record whatever potential new feature or modification that has distracted us, and then get back to finishing the task at hand. The keys to making TDD work well are keeping iterations as short as possible and to staying focused on the current task.


After an iteration is complete (remember this means all existing test cases pass!), we might decide to modify our design somehow. Resist the urge to add new features during this iteration: we must keep our focus on the refactoring. For instance, an iteration to simplify our API might look like this:


  • Identify the specific method signature that needs to be changed;
  • Remove or consolidate the arguments that are not needed or overkill;
  • Run the entire set of test cases as often as we are able;
  • When we are pleased with the new method signature, and all the tests pass, the task is complete.

At each step during this process, the entire suite of tests is run often to make sure no existing behavior is inadvertently broken. Early in the process, our task list will continue to grow, but eventually it will begin to diminish until there are no more tests to write and we are satisfied with the design.



TDD adherents find many benefits from following this approach. The main advantage I find is that I am never very far from having working code, however incomplete it is in early stages.

Up Front Planning and Design


We want to write a Tic Tac Toe game where a human player plays against the computer. Following the principle “Do the simplest thing that could possibly work”, we need to think about the minimum features a game like this needs, and the simplest model that supports those features.


A minimal Tic Tac Toe game probably needs at least the ability:


  • for a human player to enter their move;
  • for a computer player to choose a move;
  • to track the state of the game;
  • to check whether moves are valid;
  • to start the game;
  • to know if someone wins;
  • to know if the game is a draw.

At this point some sort of high level sketches or mockups can be useful in guiding our thinking, as long as we don’t get bogged down in low-level details too soon. For our game, we want something very simple, a visual representation of the game board, and a simple message label to let the player know when it is their turn or if the game is over for some reason. Something like this:


TDD TicTacToe 4


One simple design that supports those features is to have two Player objects, a GameBoard, a GameManager, and a GameView/GameViewController (a simple UIKit based GUI) for actual gameplay.


Since we are not keeping score or tracking anything other than a single game, our players can be very simple objects — in fact, an instance of a string to hold a name is sufficient.


The GameBoard will handle keeping track of the current positions, validating moves, and checking for a winner or a draw.


The GameManager will manage starting a game, tracking the players and their turns, selecting a move for the computer player, and any other interactions with the GameBoard.


We will keep the user interface components very simple for our game, probably nothing more than some UIButton and UILabel objects.


Armed with this very simple description, we are now ready to begin the actual development.


Development




Remember, you will learn the most from this tutorial by following the steps and doing the work yourself, but if you get stuck, see the note at the end of the tutorial to see how to jump to specific points in the tutorial.1


Our first test


Since our GameManager will need to interact with our GameBoard and players, and initially our player objects will be simple strings, it is probably easiest to begin development of our GameBoard class.


Before we create our GameBoard test cases, we should delete the SampleTest.m file from the TDD_TicTacToeTests folder in our Xcode project.


Now lets add a subclass of GHTestCase for our GameBoard test cases:


  • Right click the TDD_TicTacToeTests folder, and select ‘New File’;
  • Create a file named ‘TestGameBoard.m’ in the TDD_TicTacToeTests folder;
  • Select “Cocoa Touch”, “Objective-C class”;
  • Make it a subclass of GHTestCase;
  • Uncheck the TDD_TicTacToe target, and check the TDD_TicTacToeTests target;
  • Save As ‘TestGameBoard.m’.

We do not need a separate .h file for our test classes, but unfortunately Xcode 4 no longer gives us the option to leave it out. Delete the TestGameBoard.h file from TDD_TicTacToeTests, then make sure our TestGameBoard.m file looks like this:


// ttt_1_001

#import <GHUnitIOS/GHUnitIOS.h>
#import <OCMock/OCMock.h>

@interface TestGameBoard : GHTestCase { }
@end

@implementation TestGameBoard

@end

At this point, build and run the TicTacToeTests target in the simulator to ensure Xcode finds all the appropriate libraries. Now it is time to decide what we should test first.


Since the GameBoard is all about tracking the state of the game, a good place to start is to test for valid and invalid moves. But, we have already run into some missing pieces from our design: How should we track our moves and the game board itself? Fortunately for us, we don’t have to decide everything at once — lets just decide how we want to address each potential move.


Since traditional Tic Tac Toe is played in a 3×3 grid, the simplest approach is to simply reference the row and column number of the move. For convenience sake, lets use zero based notation. So, a valid row would be 0, 1, or 2, and likewise for columns. We now have done enough design work to write our first test case.


Add this method to TestGameBoard.m:


// ttt_1_002

- (void) testValidMove_row0_col0 {
GameBoard *gameBoard = [[GameBoard alloc] init];

[gameBoard movePlayer:@"playerA" row:0 col:0];
GHAssertEqualStrings([gameBoard playerAtRow:0 col:0], @"playerA",
@"playerAt... should return 'playerA'");

[gameBoard release];
}

This test case instantiates a new gameBoard, attempts to make a move for playerA, and then verifies that the gameBoard actually accepted the move by using a GHUnit macro “GHAssertEqualStrings”.


Being reasonably observant, you of course realize we have not yet written a GameBoard class, let alone any methods. Before we can even run the tests, we need to create a GameBoard class — after all, we don’t yet know if our test suite will find our test and run it since we cannot currently compile our project.


Add a new Objective-C class, ‘GameBoard.m’ to the TDD_TicTacToe folder. You will need to make sure you add ‘GameBoard.m’ to both the ‘TDD_TicTacToe’ target and the ‘TDD_TicTacToeTests’ target. (Because we are testing classes that reside in an Application target, we will always need to make sure we include them in both targets.)


We then need to add the import statement to TestGameBoard.m:


// ttt_1_002

#import "GameBoard.h"

Now if we try to build our project, it does compile, but with warnings that GameBoard may not respond to our methods. If we ran our tests now, with our project configured the way it currently is — our GHUnit test application crashes, which is not particularly very useful. The best method I have found when doing TDD with Xcode and GHUnit, is to add the method being tested to the class under test, but with no behavior.


Add these methods to GameBoard.h:


// ttt_1_002

@interface GameBoard : NSObject {
}

- (void) movePlayer:player row:(int) row col:(int) col;
- (NSString *) playerAtRow:(int) row col:(int) col;

@end

And empty implementations to GameBoard.m:


// ttt_1_002

@implementation GameBoard

- (void) movePlayer:player row:(int) row col:(int) col {
}

- (NSString *) playerAtRow:(int) row col:(int) col {
return nil;
}

@end

I expect our test to fail, since our implementation does nothing. Run the TDD_TicTacToeTests target in the simulator, then touch the “Run” button to execute the tests and our test case ‘testValidMove_row0_col0′ is displayed in red since it failed.


If we select the red test case in the table view, the GHUnit application will display the log output specific to this test, as in the image on the right:


TDD TicTacToe 5


We already know the cause, we did not implement anything — but seeing the test fail lets us know that in the future, if we change something that affects our expected results, our test will catch it.


This is more than just a casual reassurance. If we consistently write tests for everything we care about in our app, our confidence in making changes greatly increases. This is turn means that we can focus on the one thing we are doing, and not worry about bad side effects because we know that our tests will tell us if we break something.



I find many similarities between TDD and the GTD techniques described by David Allen in “Getting Things Done”. Both approaches give us a system where we can keep track of details without consciously and constantly thinking about them. If we are consistent with either system, our minds will know we have everything covered, and it will let us focus on our current task without those nagging “I wonder if I remembered…” thoughts coming to the surface.

Now that we have a failing test, we can write just enough code to make it pass. At this point it is very tempting to stray from the task at hand and do more than we need to make the test pass. We might be tempted to add code to store the game grid or something like that, but that is not necessary to make this test pass. In fact, there are real dangers in doing that.


The potential dangers in writing more code than you need right now include:


  • You will be writing code that does not yet have a test to validate it;
  • You will be tempted to skip writing the test later;
  • You might split your focus between too many things;
  • You will probably write code than is not necessary.

Deciding how much and what code to write is a judgment call, but it is better to err on the side of simplicity. For our example game, you could make an argument that now is a good time to add a very simple storage grid. But, lets keep things incredibly simple at this point and first make this pass. We need to remember the player that made a move and return it when asked, so add a single ivar to our interface:


// ttt_1_003

@interface GameBoard : NSObject {
NSString *player_;
}

And remember it when we make a move:


// ttt_1_003

- (void) movePlayer:player row:(int) row col:(int) col {
player_ = player;
}

- (NSString *) playerAtRow:(int) row col:(int) col {
return player_;
}

Now when we rerun our tests, we should see the test case pass:


TDD TicTacToe 6


We have taken a while to get to this point, and it is obvious to most anyone that our current implementation is not really very useful, and almost silly. But that will begin to change quickly as we add more tests.


Our second test


Knowing that our implementation is weak, we decide our second test should be to make moves by two different players to two different positions and validate the moves. So, add this test case to TestGameBoard.m:


// ttt_1_004

- (void) testTwoValidMoves {
GameBoard *gameBoard = [[GameBoard alloc] init];

[gameBoard movePlayer:@"playerA" row:0 col:0];
[gameBoard movePlayer:@"playerB" row:1 col:1];

GHAssertEqualStrings([gameBoard playerAtRow:0 col:0], @"playerA",
@"playerAt 0 0 should return 'playerA'");
GHAssertEqualStrings([gameBoard playerAtRow:1 col:1], @"playerB",
@"playerAt 1 1 should return 'playerB'");

[gameBoard release];
}

When we run our tests, we see that our new test case fails, and when we click on the test case name to see more details we see why:


TDD TicTacToe 7


This test reveals what we already suspected (okay, what we already knew). Our implementation is not good enough for our killer Tic Tac Toe app. We now need to think for just a minute about what the simplest thing we could do to make both tests pass. At this point, I think it is safe to say that some sort of grid storage is the simplest reasonable thing we can do to make both tests pass, so lets change our implementation from a single ivar to an array.


// ttt_1_005

@interface GameBoard : NSObject {
NSString * board_[3][3];
}

...

@implementation GameBoard

- (void) movePlayer:player row:(int) row col:(int) col {
board_[row][col] = player;
}

- (NSString *) playerAtRow:(int) row col:(int) col {
return board_[row][col];
}

@end

Once again, we run our tests. Seeing them both pass after our simple change begins to give us confidence that we are on the right track.


Now that we have two successful tests under our belt, it is time to take a small step back and evaluate the entire system as it stands to see if there are redundancies we can eliminate or other areas that should be refactored. Our implementation is still so simple that there is no real gain to be had yet in refactoring. But, there is a redundancy in our test class itself — we are creating a new gameBoard for each test, then releasing it. GHUnit, like other xUnit frameworks, gives us some methods to help. Let’s implement the ‘setUp’ and ‘tearDown’ methods like this in TestGameBoard.m:


// ttt_1_006

@interface TestGameBoard : GHTestCase { }
GameBoard *gameBoard;
@end

@implementation TestGameBoard

- (void) setUp {
[super setUp];

gameBoard = [[GameBoard alloc] init];
}

- (void) tearDown {
[gameBoard release];

[super tearDown];
}

- (void) testValidMove_row0_col0 {
[gameBoard movePlayer:@"playerA" row:0 col:0];
GHAssertEqualStrings([gameBoard playerAtRow:0 col:0], @"playerA",
@"playerAt... should return 'playerA'");
}

- (void) testTwoValidMoves {
[gameBoard movePlayer:@"playerA" row:0 col:0];
[gameBoard movePlayer:@"playerB" row:1 col:1];

GHAssertEqualStrings([gameBoard playerAtRow:0 col:0], @"playerA",
@"playerAt 0 0 should return 'playerA'");
GHAssertEqualStrings([gameBoard playerAtRow:1 col:1], @"playerB",
@"playerAt 1 1 should return 'playerB'");
}

@end

Since we made a change, we need to re-run our tests to ensure we didn’t break anything. Unless there is a typo or copy and paste error, our tests should still pass.


Occasionally while we are focused on one task something will come to mind that we need to remember about a different task. GTD has some applicable advice for us in this situation — we need to capture that thought into some system you trust, and then move back to what we were doing. In our case, while writing tests, making them pass, or refactoring code, we will realize other tests that need to be written, or other refactorings that need to happen.


For instance, while writing the second test and making it pass, several things came to mind that need to be tested, so let’s add a text file ‘todo.txt’ to our ‘TDD_TicTacToe’ target with these entries:



// ttt_1_007

Tests to add:
- test that GameBoard detects moves outside valid range
- test that GameBoard detects when a makes an invalid move (selects a move already made by a player)
- test that GameBoard to make sure only two players can be used for a given game


Next Time


We clearly still need a number of features and tests.


  • In part one we introduced Test Driven Development (TDD), briefly discussed how it can help your development, and began the process using a simple Tic-Tac-Toe game as our app.
  • In part two we will focus on how to think in a TDD fashion while we finish testing our model, the GameBoard class.
  • In part three will will finish a stand-alone version of our game.
  • In part four we will add some interaction with outside services to demonstrate how to use mock objects to help us test our code’s use of external services.

Here is the project as of this point with all of the code from the above tutorial.


In the meantime, if you have any questions, comments, or suggestions, feel free to add them below!




DwsjoquistWe all need the support of others to do our best work. Find other like-minded developers that will provide encouragement and motivation through local user groups, conferences or meetups. A great collection of indie iOS developers have helped me stay on track through meetups, 360iDev, twitter, and iDevBlogADay.

I regularly attend Cocoa/iPhone developer meetups in Cincinnati, Ohio and Columbus, Ohio. If you are in the central or southwest Ohio area, come join me at either monthly meetup:



Finally, here is a little more information about me, Doug Sjoquist, and how I came to my current place in life. You should follow me on twitter and subscribe to my blog. Have a great day!




1. If you get stuck, you can use the provided zip file of the full project for this tutorial. I recommend unzipping it into a separate location so you can use it to compare to your own project.

The zipped project is the end state of this tutorial, but it is possible to skip back and forth to specific points in the tutorial if you are willing to use some commands in a terminal window.


The zipped project file includes a git repository with tags for most steps we took in this tutorial. Each code listing includes a comment specifying the tag that corresponds to the current point in the tutorial, like this:


// ttt_1_000
...

Tag names are just simple labels, and this tutorial uses a very simple convention: a prefix of ‘ttt_1_’ and a three digit sequential number. (The starting point of the work we have done is tag ‘ttt_1_000′.) To avoid confusion and clutter, the tutorial itself does not reference git or tags other than to include the appropriate tag in the comments of code listings.


Unfortunately, even though Xcode 4 has built in support for git, that support does not include tags. To make use of them, you must open up a terminal window, change to the base directory of the project, and issue two basic git commands from there.


For instance, if you unzipped the project file into the directory ‘~/tmp’, you would follow these steps to jump back to the beginning of the tutorial:



Open up a terminal window and enter these commands (the prompt may look different, just type what is in bold):

localhost:~ $ cd ~/tmp/TDD_TicTacToe
localhost:~/tmp/TDD_TicTacToe $ git checkout .
localhost:~/tmp/TDD_TicTacToe $ git checkout ttt_1_000

Switch back to Xcode
Build the project
Run the TDD_TicTacToeTests target in the simulator


The git checkout . (don’t forget the ‘.’ at the end) discards any changes you made to provide a clean slate for the next step. It may not always be necessary, but it doesn’t hurt either.


The git checkout ttt_1_000 then moves the project files to the appropriate point in the tutorial.


If you leave the terminal window open, you can simply issue the git commands whenever you like to jump to a different point.



没有评论:

发表评论