It might be fun doing small tests to test how the tests work, but let's remember our goal - to test our Game class!
So, we’ve now got 2 targets in the project - the main app and tests. They are somewhat independent. Let’s connect the 2 together and perform the actual testing!
Importing an application
The code in both targets of our project coexist in the same project but technically act like complete strangers to each other. 😒🧐 In technical terms, they form different modules.
If you attempt to use the Game class or any other class in the tests code, Swift will get puzzled and assume it's an error. 😱
No need to panic! To resolve this, all we need to do is import the main module to the tests. 😎
And what would be the name of the main module?
The module name matches the name of the corresponding target, so for our main application, it will be TicTacToe
!
Let's now import it to the tests by including the following line:
import TicTacToe
Now the application module is imported to your test file.
This is good, but not enough. To understand why, let's do a little refresher of access control.
There are 5 access levels - let's illustrate them in relation to a module:
Internal is the default level (it's assigned automatically if we don't specify a level explicitly). At this level, the scope of classes and their members is limited to the module that contains the class. Considering this, our Game class and all its methods are not available outside of the main application. So when we import the TicTacToe module to the test code, we sill don't have access to Game and the other classes of the main application - because they are part of a different module.
We've got two options to solve this:
Modify the classes and methods of the main application so that they are all public. This would take quite some time - and imagine working with a much larger project! On top of that, it's not secure. Those classes and class members are probably not public for a reason, so we can't change all that just for the sake of making it available for testing!
Use the
@testable
decorator. It's placed before importing a module we intend to test. This is like the imported module will pretend to be part of the module it's being imported in. Looks like this is a more suitable option to solve our problem! 😎
So, let's make this adjustment:
@testable import TicTacToe
We now have access to the Game class and we'll be able to test it!
Implementing the tests
We can delete the testExample, and replace it with a real deal!
To name the tests, I suggest using a very practical technique called Behavior Driven Development - development motivated by behavior (BDD for short). BDD suggests creating test names as a composition of 3 parts, Given_When_Then
:
Given - The given part describes the state of the world before you begin the behavior you're specifying in this scenario. You can think of it as the pre-conditions to the test.
When - The when section is that behavior that you're specifying.
Then - The then section describes the changes you expect due to the specified behavior.
For example, if we had to name a test that validates the functionality of a "Like" activity, it could be:
GivenPostHasZeroLikes_WhenPostIsLiked_ThenPostHasOneLike
// GIVEN - post has ZERO likes
// WHEN - post is LIKED
// THEN - posy has ONE like
We figured we're going to test the Game class, so we'll need an instance of that class. For that we could declare a constant in our test file:
let game = Game()
However, this may not be the best idea as we'll likely need more than one test and each of them will use the same stance of the Game object. It might be unnecessarily challenging to always reset it to a needed state.
Likely, there's a method in the test class that is called every time before any test is executed. All we need to do is override it:
var game: Game!
override func setUp() {
super.setUp()
game = Game()
}
It's good now. We are ready for the first test.
Test 1. Validate the current card and next player on advance
Let's validate our code for the situation when we have a brand new game and, while making the first advance, we expect the title of the advanced card to be 'X' and the next player - Player2:
func testGivenGameIsAtStart_WhenAdvanced_ThenCardShouldBeMarkedWithXAndNextPlayerTwo() {
let title = game.advance(atIndex: 0)
XCTAssertEqual(title, "X")
XCTAssertEqual(game.nextPlayer, .two)
}
Let's analyze:
Assuming the game is at its very beginning (that's guaranteed by the setUp method), we've advanced the game for the first time and captured the card title - the
title
constant.We've evaluated the
title
constant value against the expected value, "X."We've evaluated the
nextPlayer
property value against the expected value,.one
.
Done! Let's test it - run the test and observe that it turns GREEN! 💚
Prepare for the future
We can anticipate that while testing this game we'll have to advance through the game a lot. Instead of repeating the same lines a specific number of times.
Let's implement a few helper elements:
enum WinningVariant {
case row, column, diagonalOne, diagonalTwo
}
func makeWinningIndices(player: Player, variant: WinningVariant) -> [Int] {
switch variant {
case .row: return player == .one ? [0, 7, 1, 8, 2] : [6, 0, 7, 1, 8, 2]
case .column: return player == .one ? [0, 1, 3, 5, 6] : [7, 0, 1, 3, 5, 6]
case .diagonalOne: return player == .one ? [0, 1, 4, 5, 8] : [7, 0, 1, 4, 5, 8]
case .diagonalTwo: return player == .one ? [2, 1, 4, 5, 6] : [7, 2, 1, 4, 5, 6]
}
}
func makeDrawIndices() -> [Int] {
return [0, 3, 6, 2, 5, 8, 1, 4, 7]
}
func advance(indices: [Int]) {
for i in 0 ..< indices.count {
_ = game.advance(atIndex: indices[i])
}
}
Let's review the above:
In this game it's essential to test the winning situations, so here we are creating an assisting enumeration specifying those variants:
WinningVariant
.We've implemented a function that will generate sample sequences of indices for each of the winning variants.
We've got a similar function that will generate advancing indices for the draw situation.
And finally, we've created a function that will advance the game filling up the cards according to the indices passed in a parameter. Notice here we don't need to specify a player as the playing turns are alternating within the advance method of the game object.
Good! We are now all set and ready to continue! 🙌
Test 2. Validate "row game over" for player one
In this test, we'll advance our game to the winning position in a row for player one and validate 3 elements:
The
game.isOver
property is expected to be true.The
game.winner
property is expected to be .one.The
game.winningIndices
properties are expected to be not nil.
func testGivenGameIsAtStart_WhenAdvancedWithCrossesInRowOne_ThenGameShouldBeOverWithWinnerAsPlayerOneAndWinningIndicesNotNil() {
advance(indices: makeWinningIndices(player: .one, variant: .row))
XCTAssertTrue(game.isOver)
XCTAssertEqual(game.winner, .one)
XCTAssertNotNil(game.winningIndices)
}
Execute the test... It works again!
Test 3. Validate "column game over" for player two
In this test, we'll advance our game to the winning position in a column for player two and validate 3 of the same elements as in our previous test. Except this time, we expect the value of the game.winner
property to be .two:
func testGivenGameIsAtStart_WhenAdvancedWithZerosInRowOne_ThenGameShouldBeOverWithWinnerAsPlayerTwoAndWinningIndicesNotNil() {
advance(indices: makeWinningIndices(player: .two, variant: .row))
XCTAssertTrue(game.isOver)
XCTAssertEqual(game.winner, .two)
XCTAssertNotNil(game.winningIndices)
}
Run the test... It works!
Test 4-6. Validate remaining winning situations
In the next 3 tests, we'll validate the remaining winning situations: column and diagonals 1 and 2 for player two. For this, it's sufficient to validate only the game.winner
property to be not nil:
func testGivenGameIsAtStart_WhenAdvancedWithCrossesInColumnOne_ThenWinnerShouldBeNotNil() {
advance(indices: makeWinningIndices(player: .two, variant: .column))
XCTAssertNotNil(game.winner)
}
func testGivenGameIsAtStart_WhenAdvancedWithCrossesInDiagonalOne_ThenWinnerShouldBeNotNil() {
advance(indices: makeWinningIndices(player: .two, variant: .diagonalOne))
XCTAssertNotNil(game.winner)
}
func testGivenGameIsAtStart_WhenAdvancedWithCrossesInDiagonalTwo_ThenWinnerShouldBeNotNil() {
advance(indices: makeWinningIndices(player: .two, variant: .diagonalTwo))
XCTAssertNotNil(game.winner)
}
Run the tests... All 3 are in tact!
Test 7. Validate the draw situation
The last test for the game class we'll need to do is to validate the draw situation. In this function, we'll also validate 3 components. Just expect different values:
The
game.isOver
property is expected to be true.The
game.winner
property is expected to be nil.The
game.winningIndices
properties are expected to be nil.
func testGivenGameIsAtStart_WhenAdvancedToFill_ThenGameShouldBeOverWithWinnerAsNilAndWinningIndicesAsNil() {
advance(indices: makeDrawIndices())
XCTAssertTrue(game.isOver)
XCTAssertNil(game.winner)
XCTAssertNil(game.winningIndices)
}
Run the test... Observe that it works! You are awesome! 😊
Let's Recap!
The names of the tests are written following Behavior Design Development as a composition of three parts: Given, When, Then.
The
setup
method is recalled before each test. It ensures initialization.Test code must be treated the same way as the main code - nice and clean. Refactoring is encouraged!
There are several variants of
XCTAssert
. It's best to use the most suitable version for each test to improve the readability and clarity of the intentions of the tests.