At this time, we know how TDD works: writing tests before, implementation after tests, and refactoring later on. We are going to pass through that process and only show the final result for each requirement. It is left to you to figure out the iterative red-green-refactor process. Let's make this more interesting, if possible, by using a Hamcrest framework in our tests.
As described in Chapter 2, Tools, Frameworks, and Environment, Hamcrest improves our tests readability. It turns assertions more semantic and comprehensive at the time that complexity is reduced by using matchers. When a test fails, the error shown becomes more expressive by interpreting the matchers used in the assertion. A message could also be added by the developer.
The Hamcrest
library is full of different matchers for different object types and collections. Let's start coding and get a taste of it.
We will start with the first requirement.
There is no big challenge with this requirement. The board bounds are specified, but there's no described behavior in it; just the consideration of an empty board when the game starts. That means zero discs when the game begins. However, this requirement must be taken into account later on.
This is how the test
class looks for this requirement. There's a method to initialize the tested
class in order to use a completely fresh object in each test. There's also the first test to verify that there's no disc when we start the game, meaning that all board positions are empty:
public class Connect4TDDSpec { private Connect4TDD tested; @Before public void beforeEachTest() { tested = new Connect4TDD(); } @Test public void whenTheGameIsStartedTheBoardIsEmpty() { assertThat(tested.getNumberOfDiscs(), is(0)); } }
This is the implementation of the second requirement.
Runtime Exception
is thrownRuntime Exception
is thrownAlso, these other tests are derived from the first requirement. They are related to the board limits or board behaviour.
The Java implementation of the afore mentioned tests is as follows:
@Test public void whenDiscOutsideBoardThenRuntimeException() { int column = -1; exception.expect(RuntimeException.class); exception.expectMessage("Invalid column " + column); tested.putDiscInColumn(column); } @Test public void whenFirstDiscInsertedInColumnThenPositionIsZero() { int column = 1; assertThat(tested.putDiscInColumn(column), is(0)); } @Test public void whenSecondDiscInsertedInColumnThenPositionIsOne() { int column = 1; tested.putDiscInColumn(column); assertThat(tested.putDiscInColumn(column), is(1)); } @Test public void whenDiscInsertedThenNumberOfDiscsIncreases() { int column = 1; tested.putDiscInColumn(column); assertThat(tested.getNumberOfDiscs(), is(1)); } @Test public void whenNoMoreRoomInColumnThenRuntimeException() { int column = 1; int maxDiscsInColumn = 6; // the number of rows for (int times = 0; times < maxDiscsInColumn; ++times) { tested.putDiscInColumn(column); } exception.expect(RuntimeException.class); exception .expectMessage("No more room in column " + column); tested.putDiscInColumn(column); }
This is the necessary code to satisfy the tests:
private static final int ROWS = 6; private static final int COLUMNS = 7; private static final String EMPTY = " "; private String[][] board = new String[ROWS][COLUMNS]; public Connect4TDD() { for (String[] row : board) Arrays.fill(row, EMPTY); } public int getNumberOfDiscs() { return IntStream.range(0, COLUMNS) .map(this::getNumberOfDiscsInColumn).sum(); } private int getNumberOfDiscsInColumn(int column) { return (int) IntStream.range(0, ROWS) .filter(row -> !EMPTY .equals(board[row][column])) .count(); } public int putDiscInColumn(int column) { checkColumn(column); int row = getNumberOfDiscsInColumn(column); checkPositionToInsert(row, column); board[row][column] = "X"; return row; } private void checkColumn(int column) { if (column < 0 || column >= COLUMNS) throw new RuntimeException( "Invalid column " + column); } private void checkPositionToInsert(int row, int column) { if (row == ROWS) throw new RuntimeException( "No more room in column " + column); }
The third requirement specifies the game logic.
These tests cover the verification of the new functionality. For the sake of simplicity, the red player will always start the game:
@Test public void whenFirstPlayerPlaysThenDiscColorIsRed() { assertThat(tested.getCurrentPlayer(), is("R")); } @Test public void whenSecondPlayerPlaysThenDiscColorIsRed() { int column = 1; tested.putDiscInColumn(column); assertThat(tested.getCurrentPlayer(), is("G")); }
A couple of methods need to be created to cover this functionality. The switchPlayer
method is called before returning the row in the putDiscInColumn
method:
private static final String RED = "R"; private static final String GREEN = "G"; private String currentPlayer = RED; public Connect4TDD() { for (String[] row : board) Arrays.fill(row, EMPTY); } public String getCurrentPlayer() { return currentPlayer; } private void switchPlayer() { if (RED.equals(currentPlayer)) currentPlayer = GREEN; else currentPlayer = RED; } public int putDiscInColumn(int column) { ... switchPlayer(); return row; }
Next, we should let the player know the status of the game.
As we are throwing exceptions when an error occurs, this is already covered, so we only need to implement these two tests. Furthermore, for the sake of testability, we need to introduce a parameter within the constructor. By introducing this parameter, the output becomes easier to test:
private OutputStream output; @Before public void beforeEachTest() { output = new ByteArrayOutputStream(); tested = new Connect4TDD( new PrintStream(output)); } @Test public void whenAskedForCurrentPlayerTheOutputNotice() { tested.getCurrentPlayer(); assertThat(output.toString(), containsString("Player R turn")); } @Test public void whenADiscIsIntroducedTheBoardIsPrinted() { int column = 1; tested.putDiscInColumn(column); assertThat(output.toString(), containsString("| |R| | | | | |")); }
One possible implementation is to pass the above tests. As you can see, the class constructor now has one parameter. This parameter is used in several methods to print the event or action description:
private static final String DELIMITER = "|"; public Connect4TDD(PrintStream out) { outputChannel = out; for (String[] row : board) Arrays.fill(row, EMPTY); } public String getCurrentPlayer() { outputChannel.printf("Player %s turn%n", currentPlayer); return currentPlayer; } private void printBoard() { for (int row = ROWS - 1; row >= 0; row--) { StringJoiner stringJoiner = new StringJoiner(DELIMITER, DELIMITER, DELIMITER); Stream.of(board[row]) .forEachOrdered(stringJoiner::add); outputChannel .println(stringJoiner.toString()); } } public int putDiscInColumn(int column) { ... printBoard(); switchPlayer(); return row; }
This requirement tells the system whether the game is finished.
There are two conditions to test. The first condition is that new games must be unfinished; the second condition is that full board games must be finished:
@Test public void whenTheGameStartsItIsNotFinished() { assertFalse("The game must not be finished", tested.isFinished()); } @Test public void whenNoDiscCanBeIntroducedTheGamesIsFinished() { for (int row = 0; row < 6; row++) for (int column = 0; column < 7; column++) tested.putDiscInColumn(column); assertTrue("The game must be finished", tested.isFinished()); }
This is the first win condition requirement.
In fact, this requires one single check. If the current inserted disc connects other three discs in a vertical line, the current player wins the game:
@Test public void when4VerticalDiscsAreConnectedThenPlayerWins() { for (int row = 0; row < 3; row++) { tested.putDiscInColumn(1); // R tested.putDiscInColumn(2); // G } assertThat(tested.getWinner(), isEmptyString()); tested.putDiscInColumn(1); // R assertThat(tested.getWinner(), is("R")); }
There are a couple of changes to the putDiscInColumn
method. Also, a new
method called checkWinner
has been created:
private static final int DISCS_TO_WIN = 4; private String winner = ""; private void checkWinner(int row, int column) { if (winner.isEmpty()) { String colour = board[row][column]; Pattern winPattern = Pattern.compile(".*" + colour + "{" + DISCS_TO_WIN + "}.*"); String vertical = IntStream.range(0, ROWS) .mapToObj(r -> board[r][column]) .reduce(String::concat).get(); if (winPattern.matcher(vertical).matches()) winner = colour; } }
This is the second win condition, which is pretty similar to the previous one.
This time, we are trying to win the game by inserting discs into adjacent columns:
@Test public void when4HorizontalDiscsAreConnectedThenPlayerWins() { int column; for (column = 0; column < 3; column++) { tested.putDiscInColumn(column); // R tested.putDiscInColumn(column); // G } assertThat(tested.getWinner(), isEmptyString()); tested.putDiscInColumn(column); // R assertThat(tested.getWinner(), is("R")); }
The last requirement is the last win condition.
We need to perform valid game movements to achieve the condition. In this case, we need to test both diagonals across the board: from top-right to bottom-left and from bottom-right to top-left. The following tests use a list of columns to recreate a full game to reproduce the scenario under test:
@Test public void when4Diagonal1DiscsAreConnectedThenThatPlayerWins() { int[] gameplay = new int[] {1, 2, 2, 3, 4, 3, 3, 4, 4, 5, 4}; for (int column : gameplay) { tested.putDiscInColumn(column); } assertThat(tested.getWinner(), is("R")); } @Test public void when4Diagonal2DiscsAreConnectedThenThatPlayerWins() { int[] gameplay = new int[] {3, 4, 2, 3, 2, 2, 1, 1, 1, 1}; for (int column : gameplay) { tested.putDiscInColumn(column); } assertThat(tested.getWinner(), is("G")); }
Again, the checkWinner
method needs to be modified, adding new board verifications:
if (winner.isEmpty()) { int startOffset = Math.min(column, row); int myColumn = column – startOffset, myRow = row - startOffset; StringJoiner stringJoiner = new StringJoiner(""); do { stringJoiner .add(board[myRow++][myColumn++]); } while (myColumn < COLUMNS && myRow < ROWS); if (winPattern .matcher(stringJoiner.toString()) .matches()) winner = currentPlayer; } if (winner.isEmpty()) { int startOffset = Math.min(column, ROWS - 1 - row); int myColumn = column – startOffset, myRow = row + startOffset; StringJoiner stringJoiner = new StringJoiner(""); do { stringJoiner .add(board[myRow--][myColumn++]); } while (myColumn < COLUMNS && myRow >= 0); if (winPattern .matcher(stringJoiner.toString()) .matches()) winner = currentPlayer; }
Using TDD, we got a class with a constructor, five public methods, and six private methods. In general, all methods look pretty simple and easy to understand. In this approach, we also get a big method to check winner conditions: checkWinner
. The advantage is that this approach has useful tests to guarantee that future modifications do not alter the behavior of the method. Code coverage wasn't the goal, but we got a high percentage.
Additionally, for testing purposes, we refactored the constructor of the class to accept the output channel as a parameter. If we need to modify the way the game status is printed, it will be easier that way than replacing all the uses in the traditional approach. Hence, it is more extensible.
In large projects, when you detect that a great number of tests must be created for a single class, this enables you to split this class following the Single Responsibility Principle. As the output printing was delegated to an external class passed in a parameter in initialization, a more elegant solution would be to create a class with high-level printing methods. This is just to keep the printing logic separated from the game logic. These are examples of benefits of good design using TDD.
The code of this approach is available at https://bitbucket.org/vfarcic/tdd-java-ch05-design.git.
3.145.73.207