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:

  1. Test Driven Development in SwiftUI - Part 1
  2. Test Driven Development in SwiftUI - Part 2
  3. Understanding Minimax Algorithm with Tic Tac Toe
  4. Test Driven Development in SwiftUI - Part 3
  5. 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

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

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

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

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.