Tic Tac Toe: Unit Tests

One of the biggest investments in time/money you have as a game designer goes into doing graphics and user interfaces. It’s important to check out the game logic before putting a bunch of UI into place. The UI is more difficult to debug than the game logic is, so it’s a good thing to make sure your game logic is solid and functional before trying to plug it into the UI. That eliminates a possible class of bugs and allows you to debug faster. The game logic should be independently testable without any user input. This is an ideal place to create unit tests.

Unit tests can be set up to check every single possible state of a function. Apple has a built in testing framework called XCTest. This framework allows boolean comparisons, value comparisons, and whether a function properly throws an error. There is a guide to writing good unit tests in Xcode here.

The reason I made all of these stand alone functions was because it would make them easier to unit test. Instead of having to create a View object and call these methods on those objects, I can just call functions and pass them state to test each condition within them.

I don’t want to repeat all of the code I already wrote that I am testing, so if you want to refer back to what I am testing, check out my previous post here.

Testing Reset Board

The smallest and easiest function to test is the function that creates the array that holds all of the tile state. This function has no parameters and returns one object that will consistently be the same no matter what.

Here is the test function for resetBoard():

func testResetBoard() {
	let accurateBoard:[TileState] = [TileState.notSelected,
					 TileState.notSelected,
					 TileState.notSelected,
					 TileState.notSelected,
					 TileState.notSelected,
				         TileState.notSelected,
					 TileState.notSelected,
					 TileState.notSelected,
					 TileState.notSelected]
		
	let testBoard = resetBoard()
		
	XCTAssertEqual(accurateBoard, testBoard)
}

The first variable of this test is to create an instance of what I expect the board to look like. I didn’t use a for loop to create the board because that is the code I am testing within my function. If I use the same code to create my test cases, then the tests will never fail, but the code still may not work the way I expect.

The next variable is generating a board using the resetBoard() function.

Finally, I am running an XCTest function to verify that the two variables are the same. XCTest has functions to assert equality and inequality. I am checking that they have the same capacity and hold the same data types. I ran the test and the test passed, so I know that my resetBoard() function will give me an array of nine TileState.notSelected instances.

Testing Switch Player

The switchPlayer function is slightly more involved than the resetBoard() function. The GameState enum contains three possible cases that need to be tested:

  • The current player is Player A
  • The current player is Player B
  • The game hasn’t started yet and no one is the current player.

Each of these cases will need to be tested to ensure that this function works properly. First, I want to create instances of Player A and Player B to plug into my tests. I could just create them within the function call, but I like to have labels for things:

func testSwitchPlayer() {
	// Player Objects
	let playerA = GameState.playerA
	let playerB = GameState.playerB

The first case I will test is what happens when Player A is the current player:

// Player A Case
let firstPlayerTest = switchPlayer(currentPlayer: playerA)
XCTAssertEqual(firstPlayerTest, playerB)

According to our function logic, if Player A is the current player, then switching the player should return an instance of Player B. Likewise, starting with Player B should return an instance of Player A:

// Player B Case
let secondPlayerTest = switchPlayer(currentPlayer: playerB)
XCTAssertEqual(secondPlayerTest, playerA)

The final test case is what happens where there is no current player. I determined in my logic that Player A would always start, so if we are switching the current player at the beginning of the game, Player A should always be the returned value:

	// Not Currently Playing Case
	let thirdPlayerTest = switchPlayer(
            currentPlayer: GameState.notPlaying)
	XCTAssertEqual(thirdPlayerTest, playerA)
}

Again, this test passes and I have verified each possible test case.

Testing Make Move

One reason I am testing these in order of complexity is so that I can use some of these tested methods within my future tests. For the makeMove() function, I need a lot of instances of a game board. I don’t want to have to type out all of these boards like a monkey, so being able to just generate a board using my resetBoard() function is very useful. I feel comfortable using that for this because I have tested it and verified it works.

First I want to create some reusable objects for setting up my assertions:

// Objects
let playerA = GameState.playerA
let playerB = GameState.playerB
let notPlaying = GameState.notPlaying

let selectedA = TileState.playerA
let selectedB = TileState.playerB
		
let blankBoard = resetBoard()

I have to create a .notPlaying test case even though I do not believe there will be a point in my code where it actually gets used. It is for the sake of safety and completeness that I include it and I need to test it to verify that it’s benign if it does ever wind up getting used.

First I want to check what happens if Player A makes a move:


// Player A move
let firstNewBoard = makeMove(tile: 0, currentPlayer: playerA, board: blankBoard)
XCTAssertNotEqual(blankBoard, firstNewBoard)
XCTAssertEqual(firstNewBoard[0], TileState.playerA)
XCTAssertEqual(firstNewBoard[5], blankBoard[5])

Since makeMove() returns a new array of TileState, our blankBoard is never mutated and can be used as a reference point against any board that is generated by makeMove(). Since I have generated a new board called firstNewBoard that should be different from blankBoard, I want to verify that they are in fact different. I also want to verify that the item I mutated in the function call actually changed to what I expect. Finally, I want to verify that nothing else changed. I didn’t write a test case for every element, so I chose one to test. I feel this is good enough and I don’t really want to write a billion tests around this when I feel reasonably sure this works as it should with a random sample. Next, I want to do the same using Player B:

// Player B move
let secondNewBoard = makeMove(tile: 2, currentPlayer: playerB, board: blankBoard)
XCTAssertNotEqual(blankBoard, secondNewBoard)
XCTAssertEqual(secondNewBoard[2], TileState.playerB)
XCTAssertEqual(secondNewBoard[8], blankBoard[8])

This code looks substantially similar to the Player A code, but I did want to change the tiles with the moves and the random check just so I wouldn’t accidentally have an edge case that happened to pass my tests. I have done this before and it makes you feel rather silly.

So far I have tested both Player A and Player B with a blank board. I would like to now write a few tests that simulate an actual game to verify that this works out of isolation:

// Moves by both Player A and Player B
let firstMove = makeMove(tile: 4, currentPlayer: playerA, board: blankBoard)
let secondMove = makeMove(tile: 0, currentPlayer: playerB, board: firstMove)
let thirdMove = makeMove(tile: 2, currentPlayer: playerA, board: secondMove)
		
XCTAssertNotEqual(firstMove, blankBoard)
XCTAssertNotEqual(secondMove, firstMove)
XCTAssertNotEqual(thirdMove, secondMove)
		
XCTAssertEqual(thirdMove[4], selectedA)
XCTAssertEqual(thirdMove[0], selectedB)
XCTAssertEqual(thirdMove[2], selectedA)

I simulated a game through the first three moves. I used the previous move’s result as the parameter for the next move’s function. The tests I wrote were to verify that the board does in fact change after each method call. I am also running tests on the final board to verify that a move made in the first board continues to be passed down through each successive move.

Finally I am adding my sanity check that if I am passing in a not playing state that the board does not change:

	// Not Playing move
	let notSelectedBoard = makeMove(tile: 5, currentPlayer: notPlaying, board: blankBoard)
	XCTAssertEqual(notSelectedBoard, blankBoard)
}

I am not testing this case as rigorously because I don’t anticipate it ever happening. Also, if it does, it shouldn’t really change much of anything.

This test also passed without issue. (Note: I am writing this blog post and my tests in parallel. Had something failed, I would have left the previous code as is and would include the test failure and explain why. These tests have been passing because the functions were designed very simply, making it easier to write good test cases, which was kind of the point going in.)

Test Win Game

I’m finally testing my monster function that includes all the various ways a turn can end. I am sure everyone is thrilled about reading two thousand words on doing repetitive unit testing logic, so I broke my tests up on this method to allow me to explain them without repeating myself a billion times.

First I will check for the diagonal win conditions, mainly because there are only two of them and that gives you the general idea about how the rest of the tests would be set up for vertical and horizontal wins. Again, I start with my game objects:

// Objects
let playerA = GameState.playerA
let playerB = GameState.playerB
		
let playerAWin = GameEndState.playerAWin
let playerBWin = GameEndState.playerBWin
		
let blankBoard = resetBoard()

The checkForWin() function returns an optional game end state. It’s optional because it’s possible that there are still moves to be make and no one has won yet. Since I am checking for wins here, I made objects for win conditions. When I test for other conditions I will create different objects.

Here are my tests for an upper left diagonal win:

// Check upper left win
let upperLeft1 = makeMove(tile: 0, currentPlayer: playerA, board: blankBoard)
let upperLeft2 = makeMove(tile: 4, currentPlayer: playerA, board: upperLeft1)
let upperLeft3 = makeMove(tile: 8, currentPlayer: playerA, board: upperLeft2)
		
let checkForUpperLeftWin = checkForWin(currentPlayer: playerA, board: upperLeft3)
let notAWinYetUpperLeft = checkForWin(currentPlayer: playerA, board: upperLeft2)
let wrongPlayerUpperLeft = checkForWin(currentPlayer: playerB, board: upperLeft3)
		
XCTAssertEqual(checkForUpperLeftWin, playerAWin)
XCTAssertNil(notAWinYetUpperLeft)
XCTAssertNil(wrongPlayerUpperLeft)

I generated a not quite accurate game board by simply giving Player A a win without letting Player B have any turns because I am a bad person. I look at this data and see a few places we can test to verify that this code works. We test the actual win condition to ensure that a win is returned. I also tested the move before the win to verify that the GameEndState returns nil. Finally, we are checking whether the current player has won or not, not if a win exists on the board. Even though there should be a win on the board, it is not for Player B, so this should come back nil as well.

I did the same code pretty much for the upper right win case and ran the test. It passed, so now I am moving on to testing for a draw.

A draw uses some different cases from our state objects than a win, so I need to create some new object instances:

// Objects
let playerA = GameState.playerA
let draw = GameEndState.draw
		
let selectedA = TileState.playerA
let selectedB = TileState.playerB
let notSelected = TileState.notSelected

Since I don’t really care about checking for wins, I just created one player instance for game state, but more importantly I included the draw state that I didn’t include in the last batch of tests. I created instances of tile state so that I could easily construct boards to test for various conditions:

// Boards
let drawBoard:[TileState] = [selectedA, selectedA, selectedB,
			     selectedB, selectedA, selectedB,
			     selectedA, selectedB, selectedB]
let blankBoard = resetBoard()
let nonBlankBoard:[TileState] = [notSelected, notSelected, notSelected,
				 notSelected, selectedA, notSelected,
				 notSelected, notSelected, notSelected]

(Trying to purposely construct a non-winning draw board is kind of a pain in the butt!)

I want to test three different scenarios:

  • There is a draw
  • The game has just begun
  • There has only been one move

One cardinal rule of unit testing is to test for failure conditions. In this context, anything where the game should continue is a failure condition. I am not checking with a win here because I am already checking for that in my other unit tests.

Here are my final tests to verify my draw condition works:

// Test variables and assertions
let drawCondition = checkForWin(currentPlayer: playerA, board: drawBoard)
let startGame = checkForWin(currentPlayer: playerA, board: blankBoard)
let firstMove = checkForWin(currentPlayer: playerA, board: nonBlankBoard)
		
XCTAssertEqual(drawCondition, draw)
XCTAssertNil(startGame)
XCTAssertNil(firstMove)

The draw tests also passed. I originally was going to test to ensure that the game continues if there isn’t a draw or a win, but that functionality is already being covered in both of those unit tests, so I don’t feel I need to create an entire function around it.

Ensuring Code Coverage

I’ve written what seems like an excessive number of unit tests, but how do I know I actually covered every failure case?

Starting in Xcode 7, Apple introduced a code coverage feature that is able to generate a report telling you how much of your code base is being touched by unit tests.

To enable this feature, you need to edit your target scheme:

Next, choose the option in Debug to enable code coverage data to be gathered:

Be sure to show the test bundle and check the code covered by the test bundle rather than the one that shows up by default. In this screenshot you see that the top bundle shows 0% test coverage of the State Utils class when that is basically the only thing being tested so far in the application.

Conclusions

The sample code so far for this project can be found here.

I know this wasn’t the most exciting post in the world, but there are a few take aways from this. One reason these tests were so easy to write was because I designed the code to be easy to test. How many of us have gone to work on a legacy code project that is impossible to update because there are vast multitudes of side effects and something completely unrelated to your feature breaks because of unnecessary dependencies?

I had some concerns about my very large function checking for win conditions and how I feel comfortable that my conditional logic will work as I intend. When I begin plugging this into the UI, if something isn’t working properly I will know that it’s an issue with the UI code and not with my game logic.

Speaking of UI, my next few posts will revolve around setting up the user interface and the interactive components of my game. I will be treating these separate from my game logic and as self contained units of functionality. Stay tuned!