The TDD implementation of Connect4

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.

Hamcrest

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.

Requirement 1

We will start with the first requirement.

Note

The board is composed by seven horizontal and six vertical empty positions.

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.

Tests

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));
    }

}

Code

This is the TDD implementation of the previous specification. Observe the simplicity of the given solution for this first requirement; a simple method returning the result in a single line:

public class Connect4TDD {

    public int getNumberOfDiscs() {
        return 0;
    }

}

Requirement 2

This is the implementation of the second requirement.

Note

Players introduce discs on the top of the columns. An introduced disc drops down the board if the column is empty. Future discs introduced in the same column will stack over the previous ones.

  • We can split this requirement into the following tests:
  • When a disc is inserted into an empty column, its position is 0
  • When a second disc is inserted into the same column, its position is 1
  • When a disc is inserted into the board, the total number of discs increases
  • When a disc is put outside the boundaries, a Runtime Exception is thrown
  • When a disc is inserted in to a column and there's no room available for it, then a Runtime Exception is thrown

Also, these other tests are derived from the first requirement. They are related to the board limits or board behaviour.

Tests

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);
}

Code

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);
}

Requirement 3

The third requirement specifies the game logic.

Note

It is a two-person game, so there is one colour for each player. One player uses red ('R') and the other one uses green ('G'). Players alternate turns, inserting one disc every time.

Tests

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"));
}

Code

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;
}

Requirement 4

Next, we should let the player know the status of the game.

Note

We want feedback when either an event or an error occurs within the game. The output shows the status of the board on every move.

Tests

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| | | | | |"));
}

Code

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;
}

Requirement 5

This requirement tells the system whether the game is finished.

Note

When no more discs can be inserted, the game finishes and it is considered a draw.

Tests

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());
}

Code

An easy and simple solution to these two tests is as follows:

public boolean isFinished() {
    return getNumberOfDiscs() == ROWS * COLUMNS;
}

Requirement 6

This is the first win condition requirement.

Note

If a player inserts a disc and connects more than three discs of his color in a straight vertical line, then that player wins.

Tests

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"));
}

Code

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;
    }
}

Requirement 7

This is the second win condition, which is pretty similar to the previous one.

Note

If a player inserts a disc and connects more than three discs of his color in a straight horizontal line, then that player wins.

Tests

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"));
}

Code

The code to pass this test is put into the checkWinners method:

    if (winner.isEmpty()) {
        String horizontal = Stream
          .of(board[row])
          .reduce(String::concat).get();
        if (winPattern.matcher(horizontal)
            .matches())
            winner = colour;
    }

Requirement 8

The last requirement is the last win condition.

Note

If a player inserts a disc and connects more than three discs of his colour in a straight diagonal line, then that player wins.

Tests

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"));
}

Code

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.

Code

The code of this approach is available at https://bitbucket.org/vfarcic/tdd-java-ch05-design.git.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
3.145.73.207