2011年12月6日星期二

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

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

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


Welcome back to the second part of the tutorial series, “Test Driven Development of iOS apps”.


  • In part one of the series 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.

We will pick up right where we left off in part one, but if you did not follow along part one with your own project, you can download the project file as of the end of part one. Our project already contains the GHUnit and OCMock libraries that we need for our tests, but if you would like help in setting up your own Xcode 4 project to use these libraries, check out the tutorial “Unit Testing in Xcode 4 Quick Start Guide”.


Project Recap


So far, we have:


  • A little bit of upfront planning and design,
  • A definition of “done” in the form of a small set of general requirements,
  • A defined process to follow for adding to our app,
  • Two simple tests,
  • A list of things still to be done.

In part one, we defined “done” for our Tic-Tac-Toe game as 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.

And finally, we stopped after writing only two pretty simple tests:


  • testValidMove_row0_col0
  • testTwoValidMoves


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

Clearly, we have more to accomplish before our game is ready, so let’s begin.


Revisiting Our To Do List


At this point in our project, we probably have many thoughts swirling around our minds about what features and tests we might need. We want to record those thoughts since some are probably useful bits to remember. Also, writing them down will relieve our brain of the need to keep those thoughts front and center when we are working on a particular test.


We have a ‘Tests to add:’ section in our todo list, but we only want to add very concrete tests that we know we need to this section. In part one, we recorded some, but in reading our required abilities listed above, there are few other clearly defined requirements we can add. We know enough about our design to realize other items that need to be addressed somehow, but are still undefined or uncertain. Let’s add them under a new section called ‘Things to consider’.


After adding them, our todo.txt file looks like this:



// ttt_2_001
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
- test that GameBoard requires players to alternate between turns
- test that GameBoard can detect a winning player
- test that GameBoard can detect a draw (board is full with no winner)

Things to consider:
- mechanism for computer to choose a move
- mechanism to start game
- mechanism to know whose turn it is
- mechanism for user to enter move


Write Failing Test, Make It Pass, Repeat


It is time to dive back into our TDD process, which we loosely defined in part one as:


  • 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.

Picking what behavior to add next


Since the GameBoard class has no external dependencies, testing it is straightforward. So the best choice for “what’s next” is probably to finish many of its requirements before moving on. Looking at our list of tests to add, the first item seems like a great place to start: “test that GameBoard detects moves outside valid range”.


The tendency at this point is to dive into GameBoard and start adding a method, but first let’s think about what behavior we need before writing any code or test.


Thinking in TDD fashion — Just In Time Design


The first two tests we wrote did not make any provision for invalid moves. They did not need to consider what to do about bad moves at the time, so we did nothing. But now this rather simple requirement raises the subject of error handling, and specifically the question of how we want our app to handle errors, so we need to think about this decision at a little higher level than just this one behavior.


Error handling is one of those design decisions that will affect many parts of our app, so we don’t want to just do something random here. We also don’t want to get bogged down into writing some fancy error handling framework either. It is wise to be consistent in error handling across our app, so our design decision needs to fit this particular case, as well as work for the most common cases.


Error handling in our app


There are many existing software patterns for error handling, three common ones are:


  1. Special return codes from functions — this is a common pattern in pure C/C++ development.
  2. Exceptions, along with try / catch syntax — most common in Java, but Objective-C supports a similar style.
  3. NSError — Passing an error argument to methods that might fail, this is standard behavior in many Cocoa frameworks from Apple and third parties.

Special return codes used for error conditions are probably the simplest to implement, but their big drawback is how to handle functions or methods that already need to return a value. I think we can safely discard this option.


Exceptions, as evidenced by their very name, should not be common conditions, but rather things that fall outside the expected, normal behavior of the code we are writing. Part of our decision process is deciding how “exceptional” we expect error conditions to be. We could make a reasonable case that since we control all aspects of our app, it would be very unusual for our GameBoard class to ever receive a request to make an invalid move. That might make Exceptions a reasonable choice.


It is wise to choose a pattern that fits the usage of standard libraries and frameworks, which makes using the NSError pattern a good one for any Cocoa/UIKit app. In fact, Marcus Zarra makes a very good case for it in his NSError tutorial.


At some point, we simply need to make a design decision and move forward. Fortunately, if we need to change one of those decisions later, we will have a great body of tests to make sure our app still behaves as expected. This is a big win for automated testing in general, and TDD in particular — it may not make it easier to change a decision later, but it does make it possible.


We will use NSError in our app. Fortunately, the standard NSError usage pattern is pretty basic:


  1. Create an NSError reference and set it to nil,
  2. Pass it to our method,
  3. Check its value after the method call.

The only quirk is how we pass the instance of NSError to our method. We pass in a reference (a pointer) to the NSError reference, it is a pointer to a pointer. The extra level of indirection is useful so that we only have to create an instance of NSError when we have an error, but a fuller explanation is beyond the scope of this tutorial. I encourage you to read Marcus’s article for a fuller explanation.


Our next test: test that GameBoard detects moves outside valid range


Now that our error handling decision has been made, how do we test for an invalid move? We modify our movePlayer:row:col: method to also take an NSError reference so that we can return an error if the move was invalid. Our new test case should look like this:


// ttt_2_002

- (void) testMoveOutsideRange {
NSError *error = nil;
[gameBoard movePlayer:@"playerA" row:-1 col:-1 error:&error];
GHAssertNotNil(error, @"Expected an error");
}

Since our move method now will require an additional argument, existing tests for valid moves will need to change as well. Add NSError to each of our older test cases, and check the result to make sure valid moves do not generate errors:


// ttt_2_002

- (void) testValidMove_row0_col0 {
NSError *error = nil;
[gameBoard movePlayer:@"playerA" row:0 col:0 error:&error];
GHAssertNil(error, @"Expected no error");

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

- (void) testTwoValidMoves {
NSError *error = nil;
[gameBoard movePlayer:@"playerA" row:0 col:0 error:&error];
GHAssertNil(error, @"Expected no error");

error = nil;
[gameBoard movePlayer:@"playerB" row:1 col:1 error:&error];
GHAssertNil(error, @"Expected no error");

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

When we build our test target in Xcode now, we see four warnings, ‘GameBoard’ may not respond to ‘-movePlayer:row:col:error:’. If we attempt to run our tests now, the test app will not run correctly, so let’s modify the method signature of ‘movePlayer’ to include the error argument. We will not add any behavior that depends on the argument yet, because we want to see our new test case fail.


Modify the method signature in GameBoard.h and GameBoard.m to include an NSError** argument (note the double ‘*’, the pointer to a pointer). The signature should now look like this:


// ttt_2_002

- (void) movePlayer:player row:(int) row col:(int) col error:(NSError **) error;

Now we can run our tests properly. Our existing tests still pass because nothing has really changed, but our new test fails since there is no error checking occurring yet.


Finally, we now implement the error checking using NSError by checking the range of our input and creating an instance of NSError to hold any context we want available to the calling method. Our new movePlayer method now looks like this:


// ttt_2_003

- (void) movePlayer:player row:(int) row col:(int) col error:(NSError **) error {
if ((row < 0) || (row > 2) || (col < 0) || (col > 2)) {
NSMutableDictionary *errorDetail = [NSMutableDictionary dictionary];
[errorDetail setValue:[NSString stringWithFormat:@"Invalid move %d,%d", row, col]
forKey:NSLocalizedDescriptionKey];
*error = [NSError errorWithDomain:@"GameBoard" code:100 userInfo:errorDetail];
return;
}

board_[row][col] = player;

}

Finish Tests for Error Conditions in GameBoard


We have two kinds of the items in our ‘Tests to Add’ list for our GameBoard class. Some are to detect end states in our game, and will require some more thought and discussion. But the others test for more error conditions and will be very similar to what we have already done.


Refactor time: adding standard error codes


We have reached a point where it is wise to do some refactoring before adding more tests for new behavior. Since we want to be able to test for specific errors, it will be useful to have some standard constants. Let’s create a file ‘TDD_TicTacToeConstans.h’ and add constants for our error codes. So far, we only have one type of error, so our initial version looks like this:


// ttt_2_004
//  TDD_TicTacToeConstants.h

#define kError_invalidBoardPosition 101

We need to add the import statement to GameBoard.m and TestGameBoard.m:


// ttt_2_004

#import "TDD_TicTacToeConstants.h"

then modify GameBoard.m to use the correct code


// ttt_2_004

- (void) movePlayer:player row:(int) row col:(int) col error:(NSError **) error {
if ((row < 0) || (row > 2) || (col < 0) || (col > 2)) {
NSMutableDictionary *errorDetail = [NSMutableDictionary dictionary];
[errorDetail setValue:[NSString stringWithFormat:@"Invalid move %d,%d", row, col]
forKey:NSLocalizedDescriptionKey];
*error = [NSError errorWithDomain:@"GameBoard"
code:kError_invalidBoardPosition
userInfo:errorDetail];
return;
}

board_[row][col] = player;
}

and finally, update our test case to check for this specific code:


// ttt_2_004

- (void) testMoveOutsideRange {
NSError *error = nil;
[gameBoard movePlayer:@"playerA" row:-1 col:-1 error:&error];
GHAssertNotNil(error, @"Expected an error");
GHAssertEquals([error code], (NSInteger) kError_invalidBoardPosition, @"Expected invalidBoardPosition error code");
}

(Because GHUnit uses macros for its assertions, they can be particular about the arguments. Because of this, we need to cast our error constant to be an NSInteger, since that is what [error code] returns. Otherwise, the compiler may have issues with our GHAssertEquals line.)


Since we have made changes, we need to run our tests again. They should all still pass. We have not added any new behavior, so if any tests fail, it must be in our latest set of changes.


Test for another type of invalid move


We now want to add a test to make sure a player’s move is into an available spot.


Add a new constant for our new error code:


// ttt_2_005

#define kError_invalidMove 102

Add the following test case to TestGameBoard.m:


// ttt_2_005

- (void) testInvalidMove {
NSError *error = nil;
[gameBoard movePlayer:@"playerA" row:0 col:0 error:&error];
GHAssertNil(error, @"Expected no error");

[gameBoard movePlayer:@"playerB" row:0 col:0 error:&error];
GHAssertNotNil(error, @"Expected an error");
GHAssertEquals([error code], (NSInteger) kError_invalidMove,
@"Expected invalidMove error code");
}

GameBoard.m is not yet checking for this type of error, so when we run our tests, this test should fail. Run the tests to verify that this test fails.


To make this test pass, we need to add a check for this type of error in GameBoard.m. Our new movePlayer method should look like this:


// ttt_2_006

- (void) movePlayer:player row:(int) row col:(int) col error:(NSError **) error {
if ((row < 0) || (row > 2) || (col < 0) || (col > 2)) {
NSMutableDictionary *errorDetail = [NSMutableDictionary dictionary];
[errorDetail setValue:[NSString stringWithFormat:@"Invalid move %d,%d", row, col]
forKey:NSLocalizedDescriptionKey];
*error = [NSError errorWithDomain:@"GameBoard"
code:kError_invalidBoardPosition
userInfo:errorDetail];
return;
}

if (board_[row][col] != nil) {
NSMutableDictionary *errorDetail = [NSMutableDictionary dictionary];
[errorDetail setValue:[NSString stringWithFormat:
@"Position %d,%d occupied by %@", row, col, board_[row][col]]
forKey:NSLocalizedDescriptionKey];
*error = [NSError errorWithDomain:@"GameBoard"
code:kError_invalidMove
userInfo:errorDetail];
return;
}

board_[row][col] = player;
}

Test that we never have more than two players on the board


We now need to verify that only two players are playing on our game board. First, add a new error code for this type of error:


// ttt_2_007

#define kError_tooManyPlayers 103

and add this test case to TestGameBoard.m:


// ttt_2_007

- (void) testOnlyTwoPlayersAllowed {
NSError *error = nil;
[gameBoard movePlayer:@"playerA" row:0 col:0 error:&error];
GHAssertNil(error, @"Expected no error");

[gameBoard movePlayer:@"playerB" row:0 col:1 error:&error];
GHAssertNil(error, @"Expected no error");

[gameBoard movePlayer:@"playerC" row:0 col:2 error:&error];
GHAssertNotNil(error, @"Expected an error");
GHAssertEquals([error code], (NSInteger) kError_tooManyPlayers, @"Expected tooManyPlayers error code");
}

Again, run the tests, and this new test should fail (but the others should still pass!).


In order to determine that there are only two players for a game, we will need to keep track of players that have already moved in our GameBoard class. Let’s keep it simple for now and just create an instance of NSSet to hold all player names. If we ever try to add a third player, then we have an error.


Modify GameBoard.h to add a set to store existing players:


// ttt_2_008

#import <Foundation/Foundation.h>

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

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

@end

and then modify GameBoard.m to create the set in our init method, release it in our dealloc, and use it in our movePlayer method.


// ttt_2_008

@implementation GameBoard

- (id) init {
if ((self = [super init])) {
players_ = [[NSMutableSet alloc] init];
}
return self;
}

- (void) dealloc {
[players_ release],
[super dealloc];
}

- (void) movePlayer:player row:(int) row col:(int) col error:(NSError **) error {
if ((row < 0) || (row > 2) || (col < 0) || (col > 2)) {
NSMutableDictionary *errorDetail = [NSMutableDictionary dictionary];
[errorDetail setValue:[NSString stringWithFormat:@"Invalid move %d,%d", row, col]
forKey:NSLocalizedDescriptionKey];
*error = [NSError errorWithDomain:@"GameBoard"
code:kError_invalidBoardPosition
userInfo:errorDetail];
return;
}

if (board_[row][col] != nil) {
NSMutableDictionary *errorDetail = [NSMutableDictionary dictionary];
[errorDetail setValue:[NSString stringWithFormat:@"Position %d,%d occupied by %@", row, col, board_[row][col]]
forKey:NSLocalizedDescriptionKey];
*error = [NSError errorWithDomain:@"GameBoard"
code:kError_invalidMove
userInfo:errorDetail];
return;
}

if ([players_ count] < 2) {
[players_ addObject:player];
} else {
if (![players_ containsObject:player]) {
NSMutableDictionary *errorDetail = [NSMutableDictionary dictionary];
[errorDetail setValue:[NSString stringWithFormat:@"Only two players allowed per game, cannot add %@", player]
forKey:NSLocalizedDescriptionKey];
*error = [NSError errorWithDomain:@"GameBoard"
code:kError_tooManyPlayers
userInfo:errorDetail];
return;
}
}

board_[row][col] = player;
}

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

@end

After these changes, when we run our tests, all of them should pass.


Last test for an error in GameBoard, ensure players alterate turns


We have one last error condition to test for in GameBoard, we need to make sure players take turns.


Add the new error code to our constants file:


// ttt_2_009

#define kError_playersMustAlternate 104

and this test case to TestGameBoard.m:


// ttt_2_009

- (void) testAlternatePlayers {
NSError *error = nil;
[gameBoard movePlayer:@"playerA" row:0 col:0 error:&error];
GHAssertNil(error, @"Expected no error");

[gameBoard movePlayer:@"playerA" row:0 col:1 error:&error];
GHAssertNotNil(error, @"Expected an error");
GHAssertEquals([error code], (NSInteger) kError_playersMustAlternate,
@"Expected playersMustAlternate error code");
}

By now you should be beginning to develop the TDD habit: add a failing test, run the tests, watch the new test fail, wrote code to fix it. Keep it up, before too long it will become second nature!


Now we just need to modify GameBoard to make this pass. The simplest method is to add an instance variable to store the last player that made a valid move, and make sure they don’t do two in a row.


Add and instance variable to our interface in GameBoard.h:


// ttt_2_010

NSString *lastPlayer_;

And then after all the other tests in our movePlayer method in GameBoard.m, check to make sure the same player is not trying to go twice in a row. Then once a valid move was made, remember who it was:


// ttt_2_010

- (void) movePlayer:player row:(int) row col:(int) col error:(NSError **) error {
...
if ([player isEqualToString:lastPlayer_]) {
NSMutableDictionary *errorDetail = [NSMutableDictionary dictionary];
[errorDetail setValue:[NSString stringWithFormat:@"Players must alternate turns, player: %@", player]
forKey:NSLocalizedDescriptionKey];
*error = [NSError errorWithDomain:@"GameBoard"
code:kError_playersMustAlternate
userInfo:errorDetail];
return;
}

board_[row][col] = player;
lastPlayer_ = player;
}

Finish Tests for End State Conditions in GameBoard


Now that we have tested for all the error conditions we listed, we need to move on to checking for end states in our game board.


There are two different end states: winning and a draw. Winning means one player with three in a row horizontally, vertically, or diagonally. A draw is simply defined as a full board with no winner.


Test helper classes


Writing tests for all the winning or draw states can be quite tedious without using some helper methods or classes.


One approach is to write some helper methods within the test class that takes some sort of list of moves and makes them one by one, leaving our board in the desired state. Since our game is simple and moves happen quickly, this method would work okay if we wanted to use it.


Another approach is to subclass the class being tested, adding initializers or other methods to configure the object in a known state. That way our tests can be assured of a known start state without having to go through all the normal steps to arrive at that state. We will use this method in our tests to demonstrate how to do it. We will create a separate class file for our preconfigured game board to test, and create a new test class file as well since the type of tests we are doing will require a different setup than our tests for error conditions.


Create skeleton for new test class files


Create the class PreconfiguredGameBoard as a subclass of GameBoard in the TDD_TicTacToeTests target:


// ttt_2_011

#import <Foundation/Foundation.h>
#import "GameBoard.h"

@interface PreconfiguredGameBoard : GameBoard {
}

@end

Then, create the class TestGameBoardEndStates in the TDD_TicTacToeTests target, delete the TestGameBoardEndStates.h file and modify TestGameBoardEndStates.m to look like this:


// ttt_2_011

#import <GHUnitIOS/GHUnitIOS.h>
#import "PreconfiguredGameBoard.h"
#import "TDD_TicTacToeConstants.h"

@interface TestGameBoardEndStates : GHTestCase { }
PreconfiguredGameBoard *gameBoard;
@end

@implementation TestGameBoardEndStates

- (void) setUp {
[super setUp];

gameBoard = [[PreconfiguredGameBoard alloc] init];
}

- (void) tearDown {
[gameBoard release];

[super tearDown];
}

@end

To ensure we did not break existing code, run our tests again. Since we haven’t added any tests to our new test class yet, only the six tests in TestGameBoard should be listed, but they should all still pass.


TDD TicTacToe 8


Add helper methods to preconfigure board state


We need a convenience method to set the internal state of our PreconfiguredGameBoard class. Since these tests will be checking a given state for a winner or draw, we don’t care about anything but the board itself. It will be enough to set the internal board_ array to a known set of values, then test for a particular end state.


Since we will be using this method call many times, we want to make it easy to set up a new board. Fortunately, the simplest approach to implement is also very simple to use:


// ttt_2_012

- (void) setInternalBoard:(NSString*[3][3]) newBoard {
for (int row=0; row<3; row++) {
for (int col=0; col<3; col++) {
board_[row][col] = newBoard[row][col];
}
}
}

You might find it helpful to have a method that produces a printout of the current board state in a simple, but quickly readable form. If so, add one more method to our PreconfigureGameBoard class to do that:


// ttt_2_012

- (void) displayBoard {
NSLog(@"board");
for (int row=0; row<2; row++) {
NSLog(@"%@, %@, %@", board_[row][0], board_[row][1], board_[row][2]);
}
}

(Anytime you are not sure your test board is configured correctly, simply add a call to [gameBoard displayBoard] to give yourself the assurance it is working as expected.)


Our first, simplistic test for a winner in the first row


Since the definition of a draw depends on the definition of a winning board, we need to test for winning states first. The simplest first test we can write would be to see if we can detect a winner when the first row of our board is all the same player, like this:



X  |  X  |  X
-----+-----+-----
O  |     |  O
-----+-----+-----
O  |  O  |


We can now add a test for this state:


// ttt_2_012

- (void) testFirstRowIsWinner {
[gameBoard setInternalBoard:(NSString *[3][3]) {
{@"X", @"X", @"X"},
{@"O", nil, @"O"},
{@"O", @"O", nil}
}];
GHAssertTrue([[gameBoard winner] isEqualToString:@"X"], @"X should win this game");
}

Xcode will show this warning when we build: ‘PreconfiguredGameBoard’ may not respond to ‘-winner’. To keep our testing process moving along smoothly, lets add an empty method ‘winner’ to our GameBoard class that returns nil for now. This will allow our test to run to completion and fail.


// ttt_2_012

- (NSString *) winner {
return nil;
}

When we run our tests now, our test ‘testFirstRowIsWinner’ should fail, and all the other tests should pass.


Now we need to make our test pass in the simplest way possible. Using a crude check of just the first row in our ‘winner’ method, we can make this test pass:


// ttt_2_013

- (NSString *) winner {
NSString *currentValue = board_[0][0];
if (currentValue
&& [currentValue isEqualToString:board_[0][1]]
&& [currentValue isEqualToString:board_[0][2]]) {
return currentValue;
}

return nil;
}

Running our tests now — they all pass, Woohoo!


Generalizing our test for any horizontal row


Our implementation of winner is obviously too simplistic. But since our loop of “write failing test, run tests, make test pass” is becoming a habit, and all of our tests execute quickly, it is better to err on the side of increments that are a little too small, rather than ones that try to do too many new things at once.


The obvious next step in our testing is to generalize for all horizontal rows, so let’s add a test for the second row that we know will fail:


// ttt_2_014

- (void) testSecondRowIsWinner {
[gameBoard setInternalBoard:(NSString *[3][3]) {
{@"O", nil, @"O"},
{@"X", @"X", @"X"},
{@"O", @"O", nil}
}];
GHAssertTrue([[gameBoard winner] isEqualToString:@"X"], @"X should win this game");
}

Unsurprisingly, when we run our tests, this new test fails. But we now have a reason to generalize our ‘winner’ method to handle something besides the first row. Let’s generalize it to handle any horizontal row:


// ttt_2_015

- (NSString *) winner {
for (int row=0; row<3; row++) {
NSString *currentValue = board_[row][0];
if (currentValue
&& [currentValue isEqualToString:board_[row][1]]
&& [currentValue isEqualToString:board_[row][2]]) {
return currentValue;
}
}

return nil;
}

Our method is still pretty simple, but it works when we run our tests! As we are able to move through the TDD cycle very quickly, it begins to feed an addiction to writing tests and making them pass, which can be a very good place to be.


At this point we could add a test for the third row, but if we did, it is clear than any valid row on the board is covered by our winner method.


Adding a test for the first column


Now let’s tackle the vertical. Since our distinction between rows and columns is arbitrary, we ought to be able to use a very similar pattern to check the columns for winners. First, let’s write a failing test for one of the columns:


// ttt_2_016

- (void) testFirstColIsWinner {
[gameBoard setInternalBoard:(NSString *[3][3]) {
{@"X", nil, @"O"},
{@"X", @"O", @"O"},
{@"X", @"O", nil}
}];
GHAssertTrue([[gameBoard winner] isEqualToString:@"X"], @"X should win this game");
}

By now, I’m sure you are already running the test suite as soon as we add a new test case. When you did that, you saw this test fail. Now to do the simplest thing we can to make it pass, mimic the change we made to make ‘testFirstRowWinner’ pass:


// ttt_2_017

- (NSString *) winner {
for (int row=0; row<3; row++) {
NSString *currentValue = board_[row][0];
if (currentValue
&& [currentValue isEqualToString:board_[row][1]]
&& [currentValue isEqualToString:board_[row][2]]) {
return currentValue;
}
}

NSString *currentValue = board_[0][0];
if (currentValue
&& [currentValue isEqualToString:board_[1][0]]
&& [currentValue isEqualToString:board_[2][0]]) {
return currentValue;
}

return nil;
}

We are probably moving pretty quickly by this point, but running our tests shows this test case now passes. Time to generalize columns just like we did rows.


Generalizing our test for any vertical column


It is very straightforward to generalize this for all columns, but since we are disciplined developers, we first add a failing test for the second column:


// ttt_2_018

- (void) testSecondColIsWinner {
[gameBoard setInternalBoard:(NSString *[3][3]) {
{nil, @"X", @"O"},
{@"O", @"X", @"O"},
{@"O", @"X", nil}
}];
GHAssertTrue([[gameBoard winner] isEqualToString:@"X"], @"X should win this game");
}

Clearly, you no longer need a reminder to run the tests again — so you have already seen this test fail when you ran them. Generalizing our column checking is very quick:


// ttt_2_019

- (NSString *) winner {
for (int row=0; row<3; row++) {
NSString *currentValue = board_[row][0];
if (currentValue
&& [currentValue isEqualToString:board_[row][1]]
&& [currentValue isEqualToString:board_[row][2]]) {
return currentValue;
}
}

for (int col=0; col<3; col++) {
NSString *currentValue = board_[0][col];
if (currentValue
&& [currentValue isEqualToString:board_[1][col]]
&& [currentValue isEqualToString:board_[2][col]]) {
return currentValue;
}
}

return nil;
}

And running our tests again and seeing them pass now almost gives us an adrenaline rush.


Time for some refactoring


After adding a few tests in a row, it is good to take just a moment and consider our current codebase and test cases. We need to ask ourselves questions like:


  • Is there duplication of code in our tests or our classes being tested that can be removed?
  • Am I sure my test cases are actually testing what I think they are?
  • Do the existing set of tests lead me to other things that should be tested?

In looking at our existing test cases, we realize that we might leave ourselves open to misleading results. Because we have preconfigured our game board with more positions than just the expected winner, we might have left room for false positives to occur. We can eliminate any chance of a false positive by only supplying values for the winning positions, and leaving everything else nil. Since our tests are only testing for a winning position, it does not matter if we leave every other position open.


So, let’s change all our @”O” values to nil. Our test cases should now look like this:


// ttt_2_020

- (void) testFirstRowIsWinner {
[gameBoard setInternalBoard:(NSString *[3][3]) {
{@"X", @"X", @"X"},
{nil, nil, nil},
{nil, nil, nil}
}];
GHAssertTrue([[gameBoard winner] isEqualToString:@"X"], @"X should win this game");
}

- (void) testSecondRowIsWinner {
[gameBoard setInternalBoard:(NSString *[3][3]) {
{nil, nil, nil},
{@"X", @"X", @"X"},
{nil, nil, nil}
}];
GHAssertTrue([[gameBoard winner] isEqualToString:@"X"], @"X should win this game");
}

- (void) testFirstColIsWinner {
[gameBoard setInternalBoard:(NSString *[3][3]) {
{@"X", nil, nil},
{@"X", nil, nil},
{@"X", nil, nil}
}];
GHAssertTrue([[gameBoard winner] isEqualToString:@"X"], @"X should win this game");
}

- (void) testSecondColIsWinner {
[gameBoard setInternalBoard:(NSString *[3][3]) {
{nil, @"X", nil},
{nil, @"X", nil},
{nil, @"X", nil}
}];
GHAssertTrue([[gameBoard winner] isEqualToString:@"X"], @"X should win this game");
}

And when we run our tests again, lo and behold, they all pass! By now, you should be leery of refactoring your code without a solid set automated tests supporting what you have written.


Checking the diagonals for winners


One last set of winners to test for: diagonal. First, our failing test:


// ttt_2_021

- (void) testDiagonalFrom00IsWinner {
[gameBoard setInternalBoard:(NSString *[3][3]) {
{@"X", nil, nil},
{nil, @"X", nil},
{nil, nil, @"X"}
}];
GHAssertTrue([[gameBoard winner] isEqualToString:@"X"], @"X should win this game");
}

It fails when we run our tests, as expected.


Our change to ‘winner’ to make it pass is similar to our other winner checks:


// ttt_2_022

- (NSString *) winner {
...
NSString *currentValue = board_[0][0];
if (currentValue
&& [currentValue isEqualToString:board_[1][1]]
&& [currentValue isEqualToString:board_[2][2]]) {
return currentValue;
}

return nil;
}

As you see when you run the tests, this was successful in finding a diagonal winner.


Last winner state to check


Let’s add a failing test for our final winner state, the other diagonal:


// ttt_2_023

- (void) testDiagonalFrom02IsWinner {
[gameBoard setInternalBoard:(NSString *[3][3]) {
{nil, nil, @"X"},
{nil, @"X", nil},
{@"X", nil, nil}
}];
GHAssertTrue([[gameBoard winner] isEqualToString:@"X"], @"X should win this game");
}

This test fails when we run our tests, just like always!


And our solution to this failure is not to generalize. After all, there are only two possible diagonals, and the simplest thing that works is just to check each separately like this:


// ttt_2_024

- (NSString *) winner {
...
NSString *currentValue = board_[0][0];
if (currentValue
&& [currentValue isEqualToString:board_[1][1]]
&& [currentValue isEqualToString:board_[2][2]]) {
return currentValue;
}

currentValue = board_[0][2];
if (currentValue
&& [currentValue isEqualToString:board_[1][1]]
&& [currentValue isEqualToString:board_[2][0]]) {
return currentValue;
}

return nil;
}

One last time we run our tests, and watch all twelve tests pass! We have covered all possible winning combinations, there is one last end state to check for — a draw!


Checking for a draw


We can take advantage of the fact that a draw is simply a full board with no winner, but first our failing test:


// ttt_2_025

- (void) testDrawOnFullBoardWithNoWinner {
[gameBoard setInternalBoard:(NSString *[3][3]) {
{@"X", @"O", @"X"},
{@"O", @"O", @"X"},
{@"X", @"X", @"O"}
}];
GHAssertTrue([gameBoard isDraw], @"This game should be a draw");
GHAssertNil([gameBoard winner], @"A draw should not also have a winner");
}

Go ahead and add an empty ‘isDraw’ method to our GameBoard class to keep the compiler happy:


// ttt_2_025

- (BOOL) isDraw {
return NO;
}

Notice that we checked two things in this test case: first, that the game is a draw, and second that we do not declare a draw when there is in fact a winner.


When we run our tests, this new test should fail.


Here is the simplest solution to our test:


// ttt_2_026

- (BOOL) isDraw {
// check for empty spots in the board
for (int row=0; row<3; row++) {
for (int col=0; col<3; col++) {
if (board_[row][col] == nil) {
return NO;
}
}
}
return ![self winner];
}

And indeed, this test now passes. In fact, we now have thirteen tests, and they all pass!


TDD TicTacToe 9


GameBoard Behavior Is Covered By Tests


Double checking our todo list, we see that we have covered all the behavior listed for our GameBoard class so we can remove all the existing ‘Tests to add’ from our to do list. Skimming the test cases in TestGameBoard.m one more time does not bring any new requirements or behavior to mind, so we will call GameBoard “complete” for now.


This leaves us with these ‘Things to consider’ (in a slightly better order) to think about during part 3 of our series:



// ttt_2_027
Tests to add:

Things to consider:
- mechanism to start game
- mechanism for computer to choose a move
- mechanism for user to enter move
- mechanism to know whose turn it is


Next Time


  • 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 focused on how to think in a TDD fashion while we finished 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_2_000
...

Tag names are just simple labels, and this tutorial uses a very simple convention: a prefix of ‘ttt_2_’ and a three digit sequential number. (The starting point of the work we have done is tag ‘ttt_2_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_2_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_2_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.



没有评论:

发表评论