When building a Dart package you'll have to think about which information and functionality you want to expose to the consumer. You don't want to expose too little since it can make your package unusable. On the other hand you also don't want to expose too much and make the package confusing to use.
This article is the 3rd part of the Introduction to TDD series in which we'll create a package containing the logic for Tic Tac Toe, using Test Driven Development.
The public API and TDD
The reason why the public API is so important for TDD is because the public API is exactly what we will be covering with our tests. By covering the public API we ensure that the API works as intended.
We could skip this step of designing our API and let TDD guide us. However, I like to think about the API upfront because I think it's most important that the API is easy to use, since it's going to be used by developers with a variety of skill. So when I use TDD I most often use it for the implementation. Note that our design is not set in stone and (minor) changes can come up, and that's okay!
Once you have a fully covered API you can make changes and improvements in the future with full confidence.
Creating a package project
We will start by creating a new Dart package.
There is a great chance that you're reading this because you are or want to become a Flutter developer. But for this package we do not need to depend on the Flutter framework because our package will only contain logic.
By not depending on the Flutter framework we are not limiting our package to only be used in Flutter projects. This means that our package can also be used on CLI or server projects, cool!
So let's go ahead and create a Dart package. Give it a descriptive name. I'll be calling it tic_tac_toe_game
.
Creating the game state file
We will need a file to put our game state code. Let's call the file tic_tac_toe_game_state.dart
and put it under lib/src
. We'll leave it empty for now. This file will contain the class that will represent the current state of the game.
It's common to put your files under the src
directory. Dart will hide it for the consumer to discourage them from being used directly.
Exposing our API
We have control over which files we want to expose to the consumer by exporting them in the library
-file. This file should be available under lib/<your package name>.dart
since it is generated when creating a package.
So for me this file is called lib/tic_tac_toe_game.dart
and the contents look like this:
/// Play Tic Tac Toe
///
/// Use this package to create a Tic Tac Toe Game.
library tic_tac_toe_game;
// exports go here
It contains the library name and some information about the library. On the bottom we can add our exports for the files we want to make available for the consumer. Everything that's exported here can be seen as the public API. So let's add our previously created file to it to make it available:
/// Play Tic Tac Toe
///
/// Use this package to create a Tic Tac Toe Game.
library tic_tac_toe_game;
export 'src/tic_tac_toe_game_state.dart';
Defining properties and functions
The file lib/src/tic_tac_toe_game_state.dart
will contain the class that allows the consumer to create, and play, a game of Tic Tac Toe. So with this in mind, we can think about the properties and functions we should give it to make this possible.
Representing the current state
First of all, our consumers should be able to create a representation of the current state of the game. So if we were the consumer, what would we need from our package to make this possible?
The interface
We will start by creating the abstract class in our new file (we are using an abstract class because Dart doesn't have explicit interfaces, every class can be used as an interface by implementing it):
abstract class TicTacToeGameState {}
The playing field
In the previous part of these series, we've written down the rules of the game. The game is played on a three-by-three grid. This grid exists of nine fields that can either be empty, occupied by player 1 or occupied by player 2.
So how would we represent this information in Dart code? I think there are two straight forward ways to do this. The first would be using an enum
to represent the three states like:
// Possible states of the fields
enum Field { empty, player1, player2 };
abstract class TicTacToeGameState {
// represent the 9 fields
List<Field> get fields;
}
But personally, I'm not a fan of this approach. It gives me the feeling that the empty value is just as valuable as the other two, which I think is not. Also, the enum
is now very specific to this information. So I would rather do this:
// Players
enum Player { one, two };
abstract class TicTacToeGameState {
// represent the 9 fields
List<Player?> get fields;
}
Here the enum
represents the players and is not tightly coupled to the fields
list. This way it is easier to be reused. Now fields
is represented by a List
of 'nullable' Player
objects. So a field can be either empty (null
) or contain a Player
(Player.one
or Player.two
).
Using null
is often discouraged because it can lead to null-pointer exceptions during runtime. However, since Dart 2.12, the language is sound null safe which means the compiler knows and warns you about nullable values. Dart being sound null safe makes null
a valuable option for representing empty values.
Whose turn is it?
Another piece of useful information is knowing whose next to make a move. This information can be used by the consumer to show a message like: "Player 1, you're up!". So something like this might work:
enum Player { one, two };
abstract class TicTacToeGameState {
List<Player?> get fields;
// Returns the Player that can make the next move.
// This returns null when the game is over.
Player? get turn;
}
We are reusing the Player
enum
as the return type. We've made it nullable because it should not return a Player
when the game is finished.
Game result
Our consumers should also be able to tell their users if the game is over, and who has won. If we check the rules that we've listed in the previous article, we'll see that the game can end in either a win for player 1, win for player 2 or a draw. So here's how to translate this into code:
enum Player { one, two };
enum Result { p1Win, p2Win, draw };
abstract class TicTacToeGameState {
List<Player?> get fields;
Player? get turn;
// Returns the current result state.
// Is null when the game is not over.
Result? get result;
}
As you can see I've made this return type of Result
also nullable. That's because we only have a result when the game is over. While the game is still being played, this should return null
.
With this information the consumer should be able to represent the current game state just fine, but...
Simplifying the API
We've just came up with three properties for our game state that can be used to give feedback to the players.
fields
can be used to build a visual representation of the occupied fields.turn
can be used to show which player can make the next move.result
can be used to show which player(s) won.
While this could work just fine, it might not be the most clean solution. Let's take a closer look at turn
and result
. They both return a nullable value, which is fine. But the problem lies with when they are returning null
.
Let me show you with a piece of code a consumer might write:
bool shouldShowGameOverScreen(GameState state) {
// when turn returns null we know that the game is over.
return state.turn == null;
}
In this example we use turn
to determine if the game is over. But check out the following code:
bool shouldShowGameOverScreen(GameState state) {
// when result is not null we know that the game is over.
return state.result != null;
}
You see, in this case we can use result
to get the same information. When turn
is null, we know result
is holding a value and when result
is null
, we know turn
is holding a value.
In other words, these two fields are mutually exclusive.
Together these two fields have 12 (3 from turn
and 4 from result
) different possible combinations of which most could never happen. But for our consumer that might not be clear right away and they might write unnecessary checks for it.
As I mentioned in the beginning of this article, giving the consumer too much information might be confusing, so let's straighten this out.
So when turn
is null
, result
will be either Result.p1Win
, Result.p2Win
or Result.draw
. When result
is null
, turn
will be either Player.one
or Player.two
.
Of the 12 possible combinations, only 5 are actually relevant. So the way to improve this is by combining the two fields like this:
enum Player { one, two }
enum Status { p1Turn, p2Turn, p1Win, p2Win, draw }
abstract class TicTacToeGameState {
List<Player?> get fields;
Status get status;
}
Now the consumer only has one property to worry about. This makes it not only easier for them to use it, but it also makes it easier for us to test. Remember that when we are doing TDD we will test the public API. We now have less cases to test, thus saving us some work.
I don't know about you, but I'm pretty happy with the result!
Playing the game
We found a way to represent the game state, but we're missing a way to change it. We are going to define a function that can be used to play the game.
The game is played by claiming fields. So we need to come up with a function that can be used for this. Let's call the function claimField
:
enum Player { one, two }
enum Status { p1Turn, p2Turn, p1Win, p2Win, draw }
abstract class TicTacToeGameState {
List<Player?> get fields;
Status get status;
????? claimField(????);
}
Parameters
There are nine fields to be claimed, so our function needs to know which of these is being claimed. We can simply use an int
for this, let's add it to our function:
enum Player { one, two }
enum Status { p1Turn, p2Turn, p1Win, p2Win, draw }
abstract class TicTacToeGameState {
List<Player?> get fields;
Status get status;
????? claimField(int index);
}
We also need to know who is doing the claiming so we can assign the field to the correct Player
. We could add another parameter of type Player
so the consumer can pass the Player
that is doing the claiming. But we are not going to do this, and I'll explain why.
The status
property can already tell us who's turn it is. So we can assume the consumer checks this property before calling our function. If we do that we can also use it to determine who is doing the claiming by simply checking who's turn it is.
Again we've limited the amount of errors by giving the consumer just enough to work with. If we did add the player parameter we would have to start handling cases where the wrong player is being passed.
Return type
Our game state object is going to be immutable. This means the properties, fields
and status
, will never change. Instead, we will return a new game state every time a field is claimed.
Immutable objects are way easier to work with and reason about than mutable objects. They are also way safer to work with because you know that whenever you check a property, it will always be the same.
So with this knowledge we can complete the signature of our function:
enum Player { one, two }
enum Status { p1Turn, p2Turn, p1Win, p2Win, draw }
abstract class TicTacToeGameState {
List<Player?> get fields;
Status get status;
TicTacToeGameState claimField(int index);
}
And that's it! This is everything we need for now.
What's next?
Now that we've designed a public API for our package it's time to start implementing. In the next part we will start writing our first tests and implementation.
Next article: Writing your first test in Dart