When writing tests in Dart, your programming skills can keep them maintainable and readable.
This article is the sixth in the Introduction to TDD series where we are creating a package containing the logic for the game Tic Tac Toe.
We'll use skills and techniques we'd normally use for writing code, to keep our tests maintainable and readable.
Iterating test-cases
In the previous article we started covering the different outcomes of the status
getter.
We ended up copy-pasting one test, to create multiple tests for the same requirement/rule. Most of the code in the tests for the status
getter is exactly the same. Just see for yourself:
group(
'status',
() {
test(
'should return p1Turn',
() {
final gameState = TicTacToeGameState();
expect(
gameState.status,
Status.p1Turn,
);
},
);
test(
'should return p2Turn',
() {
final testFields = [
Player.one,
null,
null,
null,
null,
null,
null,
null,
null,
];
final gameState = TicTacToeGameState.seed(fields: testFields);
expect(
gameState.status,
Status.p2Turn,
);
},
);
test(
'should return p2Turn',
() {
final testFields = [
Player.one,
null,
Player.two,
null,
Player.one,
null,
null,
null,
null,
];
final gameState = TicTacToeGameState.seed(fields: testFields);
expect(
gameState.status,
Status.p2Turn,
);
},
);
test(
'should return p2Turn',
() {
final testFields = [
null,
Player.two,
null,
Player.two,
Player.one,
Player.one,
null,
null,
Player.one,
];
final gameState = TicTacToeGameState.seed(fields: testFields);
expect(
gameState.status,
Status.p2Turn,
);
},
);
},
);
As you can see everything is the same except for the fields
we seed and the Status
that we expect.
We wouldn't except this in our normal code, so why should we do that for out tests?
It's just code
The tests we write are hardly any different from normal code. We are just calling a bunch of functions like group
, test
and except
. So why shouldn't we use all of our capabilities to make the tests better?
Introducing the loop
The first thing we will do is refactor these tests to use a loop. And to do that we will put our fields
and expected Status
into a collection of testCases
.
final testCases = <List<Player?>, Status>{};
Now we can loop over these testCases
and call test
on each of them.
testCases.forEach(
(fields, status) => test(
'$fields should return $status',
() {
final gameState = TicTacToeGameState.seed(fields: fields);
final result = gameState.status;
expect(result, status);
},
),
);
All that's left is to add the fields
and expected Status
from each test to testCases
and remove the old tests, like this:
group(
'status',
() {
final testCases = <List<Player?>, Status>{
[
null,
null,
null,
null,
null,
null,
null,
null,
null,
]: Status.p1Turn,
[
Player.one,
null,
null,
null,
null,
null,
null,
null,
null,
]: Status.p2Turn,
[
Player.one,
null,
Player.two,
null,
Player.one,
null,
null,
null,
null
]: Status.p2Turn,
[
null,
Player.two,
null,
Player.two,
Player.one,
Player.one,
null,
null,
Player.one,
]: Status.p2Turn,
};
testCases.forEach(
(fields, status) => test(
'$fields should return $status',
() {
final gameState = TicTacToeGameState.seed(fields: fields);
final result = gameState.status;
expect(result, status);
},
),
);
},
);
We've reduced our lines of code (LoC) for the status
tests by about 25%, plus we made our tests more maintainable because we only have to care about a single function.
But we can do better!
Improving readability
I don't know about you, but I find the long List
s we use in our testCases
very difficult to read.
Wouldn't it be nice if we made it more readable?
String representation
Personally, I'm a big fan of String
representations. They can be very compact which will help us reduce the LoC and also improve readability.
For example, let's check the following state:
We've represented this in our test as:
[
Player.one,
null,
Player.two,
null,
Player.one,
null,
null,
null,
null
]
But what if we could write something like:
X-O|-X-|---
It contains the same information but is way more readable and takes only a single line!
We use X
for player 1, O
for player 2, -
for null
and I've added the |
to separate the rows.
Writing the parser
To get a TicTacToeGameState
from a String
we have to write a parser.
Again, we don't want to add this to our normal code because that would change our public API.
So for we will just add it to our test file.
TicTacToeGameState ticTacToeGameStateFromString(String string) {
final fields = string.replaceAll('|', '').split('').map(
(e) {
if (e == 'X') return Player.one;
if (e == 'O') return Player.two;
return null;
},
).toList();
return TicTacToeGameState.seed(fields: fields);
}
It doesn't have to be pretty and it doesn't need to be user-friendly because this is our own code. The consumer will never touch it (I'm not saying you should care for your code!).
Refactoring the tests
So now that we have a parser function, we can use it for our tests. We can refactor our status
group
to this:
group(
'status',
() {
final testCases = <String, Status>{
'---|---|---': Status.p1Turn,
'X--|---|---': Status.p2Turn,
'X-O|-X-|---': Status.p2Turn,
'-O-|OXX|--X': Status.p2Turn,
};
testCases.forEach(
(state, status) => test(
'$state should return $status',
() {
final gameState = ticTacToeGameStateFromString(state);
final result = gameState.status;
expect(result, status);
},
),
);
},
);
And now the group is only 22 lines instead of the original 80+ and much, much more readable.
Adding more tests for player 1
For player 2 we already have three tests, but for player 1 we only have the initial state with only empty fields
. Let's add two more.
final testCases = <String, Status>{
'---|---|---': Status.p1Turn,
'-O-|XOX|O-X': Status.p1Turn, // new
'XOO|-XX|O--': Status.p1Turn, // new
'X--|---|---': Status.p2Turn,
'X-O|-X-|---': Status.p2Turn,
'-O-|OXX|--X': Status.p2Turn,
};
See how easy it is to add more tests now? great!
Testing for draw
Now that we've streamlined our way of testing we can easily add new test to cover more of our requirements. With that said, let's continue with the following rule from our game rules:
"The game ends in a draw when all fields have been claimed without a winner."
What this means is that if all fields have been claimed without one of the players having claimed three fields in a row, the game ends in a draw.
Coming up with draw situations
Just like with the previous tests we're going for a proper solution, so we will add multiple test cases for this rule so we can solve them all at once.
These are the three situations I've came up with:
All three situations have:
- no unclaimed fields.
- have no three claims in a row from the same player.
These makes them valid draw-cases.
Writing the draw tests
Now let's convert the above situations into String representations and add them to our useCases
map.
final testCases = <String, Status>{
'---|---|---': Status.p1Turn,
'-O-|XOX|O-X': Status.p1Turn,
'XOO|-XX|O--': Status.p1Turn,
'X--|---|---': Status.p2Turn,
'X-O|-X-|---': Status.p2Turn,
'-O-|OXX|--X': Status.p2Turn,
'XXO|OXX|XOO': Status.draw, // new
'OXX|XOO|XOX': Status.draw, // new
'OXX|XOO|OXX': Status.draw, // new
};
Running the test for status
now obviously fails:
$ dart test --name="status"
00:00 +6 -1: status XXO|OXX|XOO should return Status.draw [E]
Expected: Status:<Status.draw>
Actual: Status:<Status.p2Turn>
package:test_api expect
test/test_test.dart 71:13 main.<fn>.<fn>.<fn>
00:00 +6 -2: status OXX|XOO|XOX should return Status.draw [E]
Expected: Status:<Status.draw>
Actual: Status:<Status.p2Turn>
package:test_api expect
test/test_test.dart 71:13 main.<fn>.<fn>.<fn>
00:00 +6 -3: status OXX|XOO|OXX should return Status.draw [E]
Expected: Status:<Status.draw>
Actual: Status:<Status.p2Turn>
package:test_api expect
test/test_test.dart 71:13 main.<fn>.<fn>.<fn>
00:00 +6 -3: Some tests failed.
Our three new cases fail because our implementation only returns either .p1Turn
or .p2Turn
, so let's change that.
Implementing the draw
To make our implementation return .draw
as a Status
we need to refactor it. Remember that we only write the minimum amount of code to pass the test. With this in mind, we can satisfy our test with just a small change:
Status get status {
// This line is all we need to add
if (_fields.every((field) => field != null)) return Status.draw;
final p1Count = _fields.where((field) => field == Player.one).length;
final p2Count = _fields.where((field) => field == Player.two).length;
return p1Count == p2Count ? Status.p1Turn : Status.p2Turn;
}
All we've added is a check to see if all the fields have been claimed. If that's true, we know it's a .draw
.
This works because we don't yet care for situations where all fields
have been claimed and where there is a winner. We don't care about winners at all yet.
Just to be sure let's run our tests:
$ dart test --name="status"
00:00 +9: All tests passed!
Voila!
Winner takes all
Now the only statuses missing for the status
getter are the ones that tell us who've won the game. These will require the most logic, and that's why we've saved them for last.
Starting out with the easier rules will give you more coverage quickly and could prevent you from having to refactor larger pieces of code.
So when did a player win again? From our rules:
"The game ends in a win for the player that first claims three fields in a row (vertical, horizontal or diagonal)"
Player 1 wins
Just like we did with the other testCases we will come up with three situations in which Player.one
wins the game. Because the game ends as soon as a player has three-in-a-row, situations where both players have three-in-a-row could never happen.
Also note that we should actually be adding eight testCases
instead of three because there are eight ways to get three-in-a-row (3 x horizontal + 3 x vertical + 2 x diagonal), you could do that I've you'd like. I'll skip it for now.
So three valid wins for player 1 are:
We can again turn these into String representations and add them to our testCases:
final testCases = <String, Status>{
'---|---|---': Status.p1Turn,
'-O-|XOX|O-X': Status.p1Turn,
'XOO|-XX|O--': Status.p1Turn,
'X--|---|---': Status.p2Turn,
'X-O|-X-|---': Status.p2Turn,
'-O-|OXX|--X': Status.p2Turn,
'XXO|OXX|XOO': Status.draw,
'OXX|XOO|XOX': Status.draw,
'OXX|XOO|OXX': Status.draw,
'O-X|--X|-OX': Status.p1Win, // new
'O-X|-X-|XO-': Status.p1Win, // new
'-O-|XXX|O--': Status.p1Win, // new
};
And again these cases will fail when we run them:
00:01 +9 -3: Some tests failed.
The implementation to pass these testCases
are a bit cumbersome, there might be fancy ways to optimize them, but I'll leave that up to you. Also we will cover all the ways of possible three-in-a-rows even though we don't have them in the testCases
.
Status get status {
// Horizontal: top row
if (_fields[0] == Player.one &&
_fields[1] == Player.one &&
_fields[2] == Player.one) return Status.p1Win;
// Horizontal: mid row
if (_fields[3] == Player.one &&
_fields[4] == Player.one &&
_fields[5] == Player.one) return Status.p1Win;
// Horizontal: bottom row
if (_fields[6] == Player.one &&
_fields[7] == Player.one &&
_fields[8] == Player.one) return Status.p1Win;
// Vertical: left row
if (_fields[0] == Player.one &&
_fields[3] == Player.one &&
_fields[6] == Player.one) return Status.p1Win;
// Vertical: mid row
if (_fields[1] == Player.one &&
_fields[4] == Player.one &&
_fields[7] == Player.one) return Status.p1Win;
// Vertical: right row
if (_fields[2] == Player.one &&
_fields[5] == Player.one &&
_fields[8] == Player.one) return Status.p1Win;
// Diagonal: left to right
if (_fields[0] == Player.one &&
_fields[4] == Player.one &&
_fields[8] == Player.one) return Status.p1Win;
// Diagonal: right to left
if (_fields[2] == Player.one &&
_fields[4] == Player.one &&
_fields[6] == Player.one) return Status.p1Win;
if (_fields.every((field) => field != null)) return Status.draw;
final p1Count = _fields.where((field) => field == Player.one).length;
final p2Count = _fields.where((field) => field == Player.two).length;
return p1Count == p2Count ? Status.p1Turn : Status.p2Turn;
}
That's a lot of code and it makes the getter rather messy, but it does work:
00:00 +12: All tests passed!
Player 2 wins
Obviously, player 1 is not the only one that can win this game. So let's now think of three situations in which player 2 wins the game:
And add them to our testCases
:
final testCases = <String, Status>{
'---|---|---': Status.p1Turn,
'-O-|XOX|O-X': Status.p1Turn,
'XOO|-XX|O--': Status.p1Turn,
'X--|---|---': Status.p2Turn,
'X-O|-X-|---': Status.p2Turn,
'-O-|OXX|--X': Status.p2Turn,
'XXO|OXX|XOO': Status.draw,
'OXX|XOO|XOX': Status.draw,
'OXX|XOO|OXX': Status.draw,
'O-X|--X|-OX': Status.p1Win,
'O-X|-X-|XO-': Status.p1Win,
'-O-|XXX|O--': Status.p1Win,
'OOO|XXO|XX-': Status.p2Win, // new
'-XO|XOX|OOX': Status.p2Win, // new
'X-O|-XO|X-O': Status.p2Win, // new
};
Which will make our test fail once again:
00:01 +12 -3: Some tests failed.
To make these test pass we could just copy-paste all those checks for player 1 and add them as well for player 2. But that's dirty. We going to write proper code, so we'll move these checks into a separate function:
bool _hasThreeInARow(Player player) {
// Horizontal: top row
if (_fields[0] == player && _fields[1] == player && _fields[2] == player) {
return true;
}
// Horizontal: mid row
if (_fields[3] == player && _fields[4] == player && _fields[5] == player) {
return true;
}
// Horizontal: bottom row
if (_fields[6] == player && _fields[7] == player && _fields[8] == player) {
return true;
}
// Vertical: left row
if (_fields[0] == player && _fields[3] == player && _fields[6] == player) {
return true;
}
// Vertical: mid row
if (_fields[1] == player && _fields[4] == player && _fields[7] == player) {
return true;
}
// Vertical: right row
if (_fields[2] == player && _fields[5] == player && _fields[8] == player) {
return true;
}
// Diagonal: left to right
if (_fields[0] == player && _fields[4] == player && _fields[8] == player) {
return true;
}
// Diagonal: right to left
if (_fields[2] == player && _fields[4] == player && _fields[6] == player) {
return true;
}
return false;
}
Our new private function can check for the given Player
if they have claimed three fields in a row with the logic we used for player 1 before. It will return true
if that's the case, or else it returns false
.
We can now use this in our status
getter to return the right winner Status
:
Status get status {
if (_fields.every((field) => field != null)) return Status.draw;
if (_hasThreeInARow(Player.one)) return Status.p1Win;
if (_hasThreeInARow(Player.two)) return Status.p2Win;
final p1Count = _fields.where((field) => field == Player.one).length;
final p2Count = _fields.where((field) => field == Player.two).length;
return p1Count == p2Count ? Status.p1Turn : Status.p2Turn;
}
Now our status
getter is very clean again and easy to read and understand.
Running our tests also shows it made them pass:
00:01 +15: All tests passed!
Win before draw
We're almost done with the status
getter, but there is one important test case we've yet to cover. Let's take the next game state for example:
As you can see, player 1 has clearly won this round. Let's add it to the testCases
:
final testCases = <String, Status>{
'---|---|---': Status.p1Turn,
'-O-|XOX|O-X': Status.p1Turn,
'XOO|-XX|O--': Status.p1Turn,
'X--|---|---': Status.p2Turn,
'X-O|-X-|---': Status.p2Turn,
'-O-|OXX|--X': Status.p2Turn,
'XXO|OXX|XOO': Status.draw,
'OXX|XOO|XOX': Status.draw,
'OXX|XOO|OXX': Status.draw,
'O-X|--X|-OX': Status.p1Win,
'O-X|-X-|XO-': Status.p1Win,
'-O-|XXX|O--': Status.p1Win,
'XXX|OXO|OOX': Status.p1Win, // new
'OOO|XXO|XX-': Status.p2Win,
'-XO|XOX|OOX': Status.p2Win,
'X-O|-XO|X-O': Status.p2Win,
};
We've added our new test with the expectation to return .p1Win
. Let's see if that's the case:
00:01 +12 -1: status XXX|OXO|OOX should return Status.p1Win [E]
Expected: Status:<Status.p1Win>
Actual: Status:<Status.draw>
package:test_api expect
test/test_test.dart 119:13 main.<fn>.<fn>.<fn>
00:01 +15 -1: Some tests failed.
And it's not! The new test fails. When we look into the implementation we'll see why. It's because we're checking for a draw, before checking for a win. So to fix this we can easily just move the check for a draw down like so:
Status get status {
if (_hasThreeInARow(Player.one)) return Status.p1Win;
if (_hasThreeInARow(Player.two)) return Status.p2Win;
if (_fields.every((field) => field != null)) return Status.draw; // moved below the win checks
final p1Count = _fields.where((field) => field == Player.one).length;
final p2Count = _fields.where((field) => field == Player.two).length;
return p1Count == p2Count ? Status.p1Turn : Status.p2Turn;
}
So now if we re-run the tests:
00:01 +16: All tests passed!
You'll see that everything passes!
And with this we round up the coverage and implementation for the status
getter!
What's next?
In this article we've optimized our tests by making them more maintainable and readable by utilizing our skills as a developer. By simply adding a loop and a simple parser function we were able to reduce our LoC significantly!
In the next and last article we'll focus on the claimField
function where we will actually make our package playable/usable.
Next article: Handling consumer mistakes