Day21 - Isolated Tests in Practice (Part 1)
A few months before, I wrote a Tic-Tac-Toe console game in Scala. This post describes which design failures I made and how I try to eliminate them using isolated tests.
The good news is that my game has some tests. The bad news is that these tests only cover the easy-to-test test-cases. Why is that? Well, the current game implementation makes it somehow hard to test specific parts of the game. So the really bad news is that there must be design failures.
The following snippet shows pseudo-code which describes the current implementation of the game's main loop (i.e. continue playing until someone wins or there is a draw):
function play():// some game initialization code goes here ...# game loopwhile BoardStats(ticTacToeGrid) == Active:if currentPlayer == humanPlayer:// read next move for from stdin(row, column) = readRowAndColumnFromStdIn()else// it's the computers turn(row, column) = computerChooseRowAndColumn(ticTacToeGrid)if ticTacToeGrid.isValidMove(row, column):ticTacToeGrid = ticTacToeGrid.makeMove(row, column, currentPlayer /* X or O */)printToStdOut(ticTacToeGrid)currentPlayer = opponentOf(currentPlayer)# the game ended hererestart = readRestartFromStdIn()if restart:play()
This code is hard to almost impossible to test. The reason for that is that it mixes up a lot of IO operations (e.g. reading from stdin or printing to stdout) with game logic (e.g. updating the grid, alternating players, or play until the game is over). If one would write a test for the code above, this would be a perfect example of how an integrated test might look like. But we don't want these bad, integrated tests! So let's refactor!
As a first refactoring step, I introduced an explicit object which represents a game's current state, i.e.
currentPlayer (e.g. 'X'),
otherPlayer (e.g. 'O'),
humanPlayer (e.g. 'X'),
computerPlayer (e.g. 'O').
This helps us to further separate the logic from the input.
The game logic then operates on this particular game state.
Therefore, I created a method called
makeMove which receives a move + a game state and then returns a new game state and additional information as a result.
This additional information tells us for example if a player has won or not.
This simple refactoring step has made the code a bit more testable, see for yourself:
// Test Case: assert that players alternate after a valid move// arrangeval gameState = GameState(grid = new Board()currentPlayer = X,)// actval actionResult = TicTacToe.makeMove(row = 1, column = 1, gameState)val updatedGameState = actionResult.gameState// assertactionResult shouldBe a[GameUpdated] // assertion 1: game field is updated, but no one has wonupdatedGameState.grid(1, 1) shouldBe Some(X) // assertion 2: there is an 'X' on row=1/column=1updatedGameState.currentPlayer shouldBe O // assertion 3: player 'O' is now in turn
In my next post, I'll continue this refactoring session and tell you how I further separate the "bad outside world" (IO) from the game logic. But now I have to pack my bag, tomorrow we are flying to Bulgaria to the HCCamp! 😎