Test Driven Development in SwiftUI - Part 1
Test Driven Development is a process of writing unit tests for software before writing the code to get those tests to pass. Many of us struggle to write the tests before we write the software, but this has gotten easier in Xcode over the years. This article demonstrate an approach for implementing a simple version of Tic Tac Toe in SwiftUI using TDD.
Related articles on TDD in SwiftUI:
- Test Driven Development in SwiftUI - Part 1
- Test Driven Development in SwiftUI - Part 2
- Understanding Minimax Algorithm with Tic Tac Toe
- Test Driven Development in SwiftUI - Part 3
- Fixing a bug in Tic Tac Toe with TDD
Test Driven Development
Test Driven Development TDD is a process of developing software by first writing unit tests to test the functionality wanted from the software. These tests will fail. Next step is to write just enough software to make the tests pass. The code is then then refactored to make it better keeping the unit tests passing all the time. It is a short cycle of writing a single test on a desired piece of functionality, executing the test, implementing the code for the functionality and then rerunning the test. This cycle of writing failing tests and implementing software to get the tests to pass is then repeated until the application is complete.
This may seem laborious at first, but these cycles are rapid and many cycles are completed in minutes. Following the MVVM pattern, there should only be unit tests needed on the Model and possibly the ViewModel, see more on MVVM in SwiftUI.
Red Green Refactor cycle of Test Driven Development
Tic Tac Toe
Tic Tac Toe is a logical game played by two players in a 3 by 3 grid - Tic Tac Toe. The players take alternate turns in placing their marker in the grid with the first player to get 3 in a row to win. This has traditionally been played by children with pen and paper to pass the time. It is also called noughts and crosses or X's and O's.
Start of Tic Tac Toe game
Start with Unit Tests
The challenge I struggle with in TDD is writing a failing test before writing the code. The initial test code doesn't even compile. Perhaps, this can be called a failing test. Here is code to instantiate the Model, which has not been defined. More information on setting up Unit Tests is covered in Unit testing with Xcode.
1import XCTest
2@testable import TicTacToe
3
4class TicModelTests: XCTestCase {
5
6 func test_initialValue_NineCells() {
7 // Arrange
8 let ticModel = TicModel()
9
10 // Act
11
12 // Assert
13 }
14
15}
Unit test for model before model defined
Eliminate the warning
Unit tests and application code are written in parallel. The split view in Xcode is great for this, having the application code in one pane and the unit test code on the second.
A Model is added to fix the compiler error. There is now a Model that does not do anything and a unit test that passes, although there is a warning that the instantiated object is never used.
1struct TicModel {
2
3}
Initial unit test empty model
Initial Unit Tests on Model
Don't worry, I'm not going to capture screenshots after each line of code! The model for Tic Tac Toe will need to contain a structure for nine cells in the grid. The first unit test can be completed to verify that there are 9 cells.
1 func test_initialValue_NineCells() {
2 // Arrange
3 let ticModel = TicModel()
4
5 // Act
6
7 // Assert
8 XCTAssertEqual(ticModel.grid.count, 9)
9 }
10}
The Model is updated to get this test to compile and pass. Each cell in the grid can contain only one of three values 'X', 'O' or 'Blank'. An enum is defined for the cell values. The best practice is to keep the model member variables private, therefore a readonly property of the grid is exposed by the Model.
1import Foundation
2
3enum cell: String {
4 case x = "X"
5 case o = "O"
6 case b = "" // blank
7}
8
9struct TicModel {
10 private var _grid: [cell]
11
12 init() {
13 _grid = []
14 for _ in 0..<9 {
15 _grid.append(cell.x)
16 }
17 }
18
19 var grid: [cell] {
20 get { _grid }
21 }
22
23}
A second unit test is added to verify all the cells are initialised to blank. These
two initial tests create an instance of the TicModel
and ensure the grid contains 9
cells and that the cells are blank. These could possibly be combined into one test
with two asserts. It is also possible to refactor the arrange step out into the test
setup step.
1import XCTest
2@testable import TicTacToe
3
4class TicModelTests: XCTestCase {
5
6 func test_initialValue_NineCells() {
7 // Arrange
8 let ticModel = TicModel()
9
10 // Act
11
12 // Assert
13 XCTAssertEqual(ticModel.grid.count, 9)
14 }
15
16 func test_initialValue_IsBlank() {
17 // Arrange
18 let ticModel = TicModel()
19
20 // Act
21
22 // Assert
23 XCTAssertEqual((ticModel.grid.filter { $0 == Cell.b }.count), 9)
24 }
25
26}
Initial unit tests added and passing
Add ViewModel and View
The ViewModel can be added in the same way by first a unit test class
TicViewModelTests
. This will contain similar tests as it exposes the grid to the
View.
ViewModel tests
1import XCTest
2@testable import TicTacToe
3
4class TicViewModelTests: XCTestCase {
5
6 func test_initialValue_NineCells() {
7 // Arrange
8 let ticViewModel = TicViewModel()
9 // Act
10
11 // Assert
12 XCTAssertEqual(ticViewModel.grid.count, 9)
13 }
14
15 func test_initialValue_IsBlank() {
16 // Arrange
17 let ticViewModel = TicViewModel()
18
19 // Act
20
21 // Assert
22 XCTAssertEqual((ticViewModel.grid.filter { $0 == Cell.b }.count), 9)
23 }
24}
ViewModel - The initial ViewModel instantiates an instance of the Model and exposes a readonly property of the grid od cells.
1import Foundation
2
3class TicViewModel: ObservableObject {
4 @Published private var ticModel: TicModel
5
6 init() {
7 ticModel = TicModel()
8 }
9
10 var grid: [cell] {
11 get { ticModel.grid }
12 }
13
14}
Initial unit tests on Tic Tac Toe ViewModel
Add initial View
The View in MVVM should not contain any application logic and is not part of TDD. It is advantageous, especially in SwiftUI, to create the ViewModel and View as the Model is being developed. Developing an App in this way ensures the View has all the information required to display and any user input through the View is propagated to the model.
When new functionality is required a unit test can be added to validate this functionality, then the functionality added to get the test to pass. Start at the lower level and add tests for the model, implement the feature in the model and perhaps modify the tests. Next add tests for the ViewModel, then implement feature in the ViewModel. Finally add the feature to the view.
1struct TicTacToeView: View {
2 @ObservedObject private var ticVm: TicViewModel
3
4 init() {
5 ticVm = TicViewModel()
6 }
7
8 var body: some View {
9 VStack {
10 Text("Tic Tac Toe Game")
11
12 VStack(spacing:3) {
13 let n = 3
14 ForEach(0..<n, id:\.self) { r in
15 HStack(spacing:3) {
16 ForEach(0..<n, id:\.self) { c in
17 let index = (r*n) + c
18 HStack {
19 Text("\(index)")
20 Text("\(ticVm.grid[index].rawValue)")
21 .foregroundColor(.red)
22 }
23 .frame(width: 80, height: 80)
24 }
25 }
26 }
27 }
28 Spacer()
29 }
30 }
31}
The above code does not show anything from the grid, because it is initialised to blank cells. The init function can be temporarily changed to initialise to 'X' to ensure that the View is displaying the values from the grid in the model.
1 ...
2
3 init() {
4 _grid = []
5 for _ in 0..<9 {
6 _grid.append(cell.x)
7 }
8 }
9
10 ...
Initial Tic Tac Toe View displaying values from the Model
Note that some of the unit tests will fail if this is not changed back to blank.
Unit test failing with incorrect initialisation of Model
Build desired functionality in the Model
In this App, we will assume the game is played with two players taking alternate turns. The Model will have to have properties to state whether the game is over, who has won, if it is a draw as well as functionality to set the value in a cell and reset the board to start over.
Set Cell value
Add a test to ensure a cell is set to the specified value. This fails to compile until the method is added to the model.
Test
1 func test_setCell3_IsX() {
2 // Arrange
3 var ticModel = TicModel()
4
5 // Act
6 ticModel.setCell(n: 3, c: .x)
7
8 // Assert
9 XCTAssertTrue(ticModel.grid[3] == cell.x)
10 }
Add the mutating func and rerun the unit tests.
Application Code
1 mutating func setCell(n:Int, c: cell) {
2 _grid[n] = c
3 }
Running a test attempting to set a cell outside of the range of the grid causes a Fatal error.
Test
1 func test_setCell42_IsIgnored() {
2 // Arrange
3 var ticModel = TicModel()
4
5 // Act
6 ticModel.setCell(n: 42, c: .x)
7
8 // Assert
9 XCTAssertTrue(ticModel.grid.contains { $0 == cell.b } )
10 }
Add a guard statement in the setCell
method to ignore any value outside of the grid.
Application Code
1 mutating func setCell(n:Int, c: cell) {
2 guard _grid.indices.contains(n) else {
3 return
4 }
5 _grid[n] = c
6 }
A cell can only be set once in the game of Tic Tac Toe. For example, if player X puts their token in the middle square, then player O is not allowed to replace it with their token or erase the X token. We can add two unit tests for these scenarios. These tests can be added one at a time or both together, both of these tests fail - as expected.
Test
1 func test_setCellTwice_ignoreSecond() {
2 // Arrange
3 var ticModel = TicModel()
4
5 // Act
6 ticModel.setCell(n: 3, c: .x)
7 ticModel.setCell(n: 3, c: .o)
8
9 // Assert
10 XCTAssertTrue(ticModel.grid[3] == Cell.x)
11 XCTAssertEqual((ticModel.grid.filter { $0 == Cell.x }.count), 1)
12 XCTAssertEqual((ticModel.grid.filter { $0 == Cell.b }.count), 8)
13 }
14
15 func test_setCellBlank_ignored() {
16 // Arrange
17 var ticModel = TicModel()
18
19 // Act
20 ticModel.setCell(n: 3, c: .x)
21 ticModel.setCell(n: 3, c: .b)
22
23 // Assert
24 XCTAssertTrue(ticModel.grid[3] == Cell.x)
25 XCTAssertEqual((ticModel.grid.filter { $0 == Cell.x }.count), 1)
26 XCTAssertEqual((ticModel.grid.filter { $0 == Cell.b }.count), 8)
27 }
The model is updated with a guard statement to ensure that cells can only be set if they are currently blank. Ignoring these invalid inputs is enough for the model as we expect the UI layer to have a mechanism of only setting a cell once.
Application Code
1 mutating func setCell(n:Int, c: Cell) {
2 guard _grid.indices.contains(n) else {
3 return
4 }
5 guard _grid[n] == .b else {
6 return
7 }
8 _grid[n] = c
9 }
This back and forth continues of adding a test to the test suite and then implementing the code in the model and rerunning the tests. I tend to focus on tests for one method or property at a time and once they are passing run all tests again. This ensures that new functionality has not broken anything else.
These are the tests for the model with the following functionality:
- initialise a grid
- readonly view of grid
- readonly winner property
- readonly property to see if grid is full
- method to set a cell
- method to update the game status
Model Tests
1import XCTest
2@testable import TicTacToe
3
4class TicModelTests: XCTestCase {
5
6 func test_initialValue_nineCells() {
7 // Arrange
8 let ticModel = TicModel()
9
10 // Act
11
12 // Assert
13 XCTAssertEqual(ticModel.grid.count, 9)
14 }
15
16 func test_initialValue_nineBlankCells() {
17 // Arrange
18 let ticModel = TicModel()
19
20 // Act
21
22 // Assert
23 XCTAssertEqual((ticModel.grid.filter { $0 == Cell.b }.count), 9)
24 }
25
26 func test_setCell3_isX() {
27 // Arrange
28 var ticModel = TicModel()
29
30 // Act
31 ticModel.setCell(n: 3, c: .x)
32
33 // Assert
34 XCTAssertTrue(ticModel.grid[3] == Cell.x)
35 XCTAssertEqual((ticModel.grid.filter { $0 == Cell.x }.count), 1)
36 XCTAssertEqual((ticModel.grid.filter { $0 == Cell.b }.count), 8)
37 }
38
39 func test_setCellTwice_ignoreSecond() {
40 // Arrange
41 var ticModel = TicModel()
42
43 // Act
44 ticModel.setCell(n: 3, c: .x)
45 ticModel.setCell(n: 3, c: .o)
46
47 // Assert
48 XCTAssertTrue(ticModel.grid[3] == Cell.x)
49 XCTAssertEqual((ticModel.grid.filter { $0 == Cell.x }.count), 1)
50 XCTAssertEqual((ticModel.grid.filter { $0 == Cell.b }.count), 8)
51 }
52
53 func test_setCellBlank_ignored() {
54 // Arrange
55 var ticModel = TicModel()
56
57 // Act
58 ticModel.setCell(n: 3, c: .x)
59 ticModel.setCell(n: 3, c: .b)
60
61 // Assert
62 XCTAssertTrue(ticModel.grid[3] == Cell.x)
63 XCTAssertEqual((ticModel.grid.filter { $0 == Cell.x }.count), 1)
64 XCTAssertEqual((ticModel.grid.filter { $0 == Cell.b }.count), 8)
65 }
66
67 func test_setCell42_isIgnored() {
68 // Arrange
69 var ticModel = TicModel()
70
71 // Act
72 ticModel.setCell(n: 42, c: .x)
73
74 // Assert
75 XCTAssertTrue(ticModel.grid.contains { $0 == Cell.b } )
76 }
77
78 func test_initialGame_isNotWon() {
79 // Arrange
80 var ticModel = TicModel()
81
82 // Act
83 let result = ticModel.updateGameStatus()
84
85 // Assert
86 XCTAssertFalse(result)
87 }
88
89 func test_topLineX_xIsWinner() {
90 // Arrange
91 var ticModel = TicModel()
92
93 // Act
94 for i in [0,1,2] {
95 ticModel.setCell(n: i, c: .x)
96 }
97 let result = ticModel.updateGameStatus()
98
99 // Assert
100 XCTAssertTrue(result)
101 XCTAssertEqual(Winner.x, ticModel.winner)
102 }
103
104 func test_middleLineX_xIsWinner() {
105 // Arrange
106 var ticModel = TicModel()
107
108 // Act
109 for i in [3,4,5] {
110 ticModel.setCell(n: i, c: .x)
111 }
112 let result = ticModel.updateGameStatus()
113
114 // Assert
115 XCTAssertTrue(result)
116 XCTAssertEqual(Winner.x, ticModel.winner)
117 }
118
119 func test_bottomLineX_xIsWinner() {
120 // Arrange
121 var ticModel = TicModel()
122
123 // Act
124 for i in [6,7,8] {
125 ticModel.setCell(n: i, c: .x)
126 }
127 let result = ticModel.updateGameStatus()
128
129 // Assert
130 XCTAssertTrue(result)
131 XCTAssertEqual(Winner.x, ticModel.winner)
132 }
133
134 func test_leftLineO_oIsWinner() {
135 // Arrange
136 var ticModel = TicModel()
137
138 // Act
139 for i in [0,3,6] {
140 ticModel.setCell(n: i, c: .o)
141 }
142 let result = ticModel.updateGameStatus()
143
144 // Assert
145 XCTAssertTrue(result)
146 XCTAssertEqual(Winner.o, ticModel.winner)
147 }
148
149 func test_middleLineO_oIsWinner() {
150 // Arrange
151 var ticModel = TicModel()
152
153 // Act
154 for i in [1,4,7] {
155 ticModel.setCell(n: i, c: .o)
156 }
157 let result = ticModel.updateGameStatus()
158
159 // Assert
160 XCTAssertTrue(result)
161 XCTAssertEqual(Winner.o, ticModel.winner)
162 }
163
164 func test_rightLineO_oIsWinner() {
165 // Arrange
166 var ticModel = TicModel()
167
168 // Act
169 for i in [2,5,8] {
170 ticModel.setCell(n: i, c: .o)
171 }
172 let result = ticModel.updateGameStatus()
173
174 // Assert
175 XCTAssertTrue(result)
176 XCTAssertEqual(Winner.o, ticModel.winner)
177 }
178
179 func test_diagonalO_oIsWinner() {
180 // Arrange
181 var ticModel = TicModel()
182
183 // Act
184 for i in [0,4,8] {
185 ticModel.setCell(n: i, c: .o)
186 }
187 let result = ticModel.updateGameStatus()
188
189 // Assert
190 XCTAssertTrue(result)
191 XCTAssertEqual(Winner.o, ticModel.winner)
192 }
193
194 func test_diagonalX_xIsWinner() {
195 // Arrange
196 var ticModel = TicModel()
197
198 // Act
199 for i in [2,4,6] {
200 ticModel.setCell(n: i, c: .x)
201 }
202 let result = ticModel.updateGameStatus()
203
204 // Assert
205 XCTAssertTrue(result)
206 XCTAssertEqual(Winner.x, ticModel.winner)
207 }
208
209 func test_isGridFullNewGame_false() {
210 // Arrange
211 let ticModel = TicModel()
212
213 // Act
214
215 // Assert
216 XCTAssertFalse(ticModel.isGridFull)
217 }
218
219 func test_isGridFullPartialGame_false() {
220 // Arrange
221 var ticModel = TicModel()
222
223 // Act
224 for i in 0..<5 {
225 ticModel.setCell(n: i, c: .x)
226 }
227
228 // Assert
229 XCTAssertFalse(ticModel.isGridFull)
230 }
231
232 func test_isGridFullGameOver_true() {
233 // Arrange
234 var ticModel = TicModel()
235
236 // Act
237 for i in 0..<9 {
238 ticModel.setCell(n: i, c: .o)
239 }
240
241 // Assert
242 XCTAssertTrue(ticModel.isGridFull)
243 }
244
245}
Model
1import Foundation
2
3enum Cell: String {
4 case x = "X"
5 case o = "O"
6 case b = "" // blank
7}
8
9
10enum Winner {
11 case o, x, none
12}
13
14
15struct TicModel {
16 private var _grid: [Cell]
17 private var _winner: Winner
18
19 init() {
20 _grid = []
21 for _ in 0..<9 {
22 _grid.append(Cell.b)
23 }
24 _winner = .none
25 }
26
27 var grid: [Cell] {
28 get { _grid }
29 }
30
31 var winner: Winner {
32 get { _winner }
33 }
34
35 var isGridFull: Bool {
36 get { grid.filter { $0 == Cell.b }.count == 0 }
37 }
38
39 mutating func setCell(n:Int, c: Cell) {
40 guard _grid.indices.contains(n) else {
41 return
42 }
43 guard _grid[n] == .b else {
44 return
45 }
46 _grid[n] = c
47 }
48
49 mutating func updateGameStatus() -> Bool {
50 // There are 9 possible winning options in Tic Tac Toe
51 let winOptions: [Set<Int>] = [
52 [0,1,2], [3,4,5], [6,7,8],
53 [0,3,6], [1,4,7], [2,5,8],
54 [0,4,8], [2,4,6]]
55
56 let oCells: Set<Int> = Set(_grid.indices.map { _grid[$0] == Cell.o ? $0 : -1 })
57 let xCells: Set<Int> = Set(_grid.indices.map { _grid[$0] == Cell.x ? $0 : -1 })
58
59 for i in winOptions {
60 if i.intersection(xCells) == i {
61 _winner = .x
62 return true
63 }
64 if i.intersection(oCells) == i {
65 _winner = .o
66 return true
67 }
68 }
69
70 return false
71 }
72}
All ticmodel unit tests passing
Add ViewModel tests and ViewModel code
The Model exposes certain functionality from the model to the View. Unit tests are written for the ViewModel and then ViewModel code is added to get the tests to pass. These tests are written in the same iterative back and forth method as the model tests to provide the functionality needed by the View.
- initialise the Model
- readonly property for the grid
- readonly property for the winner
- readonly property to tell if the game is over
- method to set the value in a cell
1import XCTest
2@testable import TicTacToe
3
4class TicViewModelTests: XCTestCase {
5
6 func test_initialValue_nineCells() {
7 // Arrange
8 let ticViewModel = TicViewModel()
9 // Act
10
11 // Assert
12 XCTAssertEqual(ticViewModel.grid.count, 9)
13 }
14
15 func test_initialValue_isBlank() {
16 // Arrange
17 let ticViewModel = TicViewModel()
18
19 // Act
20
21 // Assert
22 XCTAssertEqual((ticViewModel.grid.filter { $0 == Cell.b }.count), 9)
23 }
24
25 func test_initialValue_noWinner() {
26 // Arrange
27 let ticViewModel = TicViewModel()
28
29 // Act
30
31 // Assert
32 XCTAssertEqual(Winner.none, ticViewModel.winner)
33 }
34
35 func test_draw_noWinner() {
36 // Arrange
37 let ticViewModel = TicViewModel()
38
39 // Act
40 let fullGrid: [Cell] = [.o, .x, .o,
41 .x, .o, .x,
42 .x, .o, .x]
43 for (n,c) in zip(0..<9, fullGrid) {
44 ticViewModel.setCell(index: n, cellValue: c)
45 }
46
47 // Assert
48 XCTAssertTrue(ticViewModel.isGameOver)
49 XCTAssertEqual(Winner.none, ticViewModel.winner)
50 }
51
52 func test_fullFrid_xWinner() {
53 // Arrange
54 let ticViewModel = TicViewModel()
55
56 // Act
57 let fullGrid: [Cell] = [.x, .o, .x,
58 .o, .x, .o,
59 .x, .o, .x]
60 for (n,c) in zip(0..<9, fullGrid) {
61 ticViewModel.setCell(index: n, cellValue: c)
62 }
63
64 // Assert
65 XCTAssertTrue(ticViewModel.isGameOver)
66 XCTAssertEqual(Winner.x, ticViewModel.winner)
67 }
68
69 func test_winNotFull_oWinner() {
70 // Arrange
71 let ticViewModel = TicViewModel()
72
73 // Act
74 ticViewModel.setCell(index: 0, cellValue: .o)
75 ticViewModel.setCell(index: 1, cellValue: .o)
76 ticViewModel.setCell(index: 2, cellValue: .o)
77
78 // Assert
79 XCTAssertTrue(ticViewModel.isGameOver)
80 XCTAssertEqual(Winner.o, ticViewModel.winner)
81 }
82
83}
All ticviewmodel unit tests passing
Conclusion
The principle behind Test Driven Development TDD is to write tests first for the desired functionality and let these tests drive the application design. It can change the approach to writing code to think more about what could go wrong and build safeguards into the software.
In this article we have a working Model and ViewModel for the Tic Tac Toe game. In part 2 we will implement the User Interface (UI) so players can play the game.