Making classes testable

Making classes testable

Sometimes classes can be difficult to test. In this article I will show you a way to make classes testable.

This article is the fifth in the Introduction to TDD series where we are creating Tic Tac Toe game logic using Test Driven Development. In the previous article we've written tests and implementation for one of the properties. In this article we'll continue with the next.

The status property

In this article we'll focus on the status property of our TicTacToeGameState class. In the previous article we've given it a temporary implementation to keep the compiler happy, but it's far from useful.

Status get status => throw 'not implemented';

As the return type tells us, this getter should be returning a Status. We'd defined Status as an enum.

enum Status { p1Turn, p2Turn, p1Win, p2Win, draw }

We should be returning one of these values based on the state of the game, which is actually just the fields.

The initial status

Just like with the fields getter we should decide on what to return as the initial value. So when fields is empty, what should we return from status?

Well, definitely not .p1Win, .p2Win or .draw since the game should not be over yet. So it should be either .p1Turn or .p2Turn. We didn't really have a rule for this, so let's decide that it's always player 1 that starts, and thus should be returning Status.p1Turn.

Writing the test for the initial status

Let's write the test for our new rule where player 1 always is the first to claim a field.

void main() {

  // pretend the fields tests are here

  group(
    'status',
    () {
      test(
        'initial state should return p1Turn',
        () {
          final gameState = TicTacToeGameState();

          expect(
            gameState.status,
            Status.p1Turn,
          ); 
        },
      );
    },
  ); 
}

Again we'll create a new group for this property. We create a new test that creates a new game state and we check if the .status getter returns Status.p1Turn.

Running our initial state test

When we run the test we'll expect it to fail, which it does:

$ dart test --name="initial state should return p1Turn"
00:01 +0 -1: test/test_test.dart: status should return p1Turn [E]                                                                                                              
  not implemented
  test/test_test.dart 11:24  TicTacToeGameState.status
  test/test_test.dart 43:21  main.<fn>.<fn>

00:01 +0 -1: Some tests failed.

Our test fails because an error is being thrown. We'll not really an error, just a String, but you get the idea. Tests usually fail because the outcome does not match the expected outcome. Dart will tell you what was the actual value versus the expected one.

In this case we simply see the 'not implemented' String being shown in the output.

Implementing the initial status

Just like we did with the initial value for fields we can easily satisfy the test by returning what is expected. So let's update our status getter:

enum Player { one, two }

enum Status { p1Turn, p2Turn, p1Win, p2Win, draw }

class TicTacToeGameState {

  List<Player?> get fields => [null,null,null,null,null,null,null,null,null];
  Status get status => Status.p1Turn;

  TicTacToeGameState claimField(int index) => throw 'not implemented';
}

So now status will always return Status.p1Turn, and that's good enough for now. It's the minimum amount of implementation needed for the test to pass. See for yourself:

dart test --name="initial state should return p1Turn"
00:00 +1: All tests passed!

Modifying our class for testing

We currently only have a test for one of five possible outcomes. The outcomes of status are related to fields. So in order to properly test status we need a way to change fields. And there are a few ways to do this...

Ways to change fields

There are two obvious ways to come to states that we can use for our tests.

Update the constructor

One way to get a TicTacToeGameState with the right fields needed for testing is to simply update the constructor to accept an entry for fields so we could do something like this:

final testFields = [Player.one,null,null,null,null,null,null,null,null];
final testState = TicTacToeGameState(fields: testFields);

This could work. But remember that our goal is to cover the whole public API and we've just made it more complicated. The consumer of our package could also use this constructor to create a TicTacToeGameState. But what if they add a List that has more (or less) than nine entries? Or that it represents an impossible state of Tic Tac Toe that could never happen. If we decided to take this approach, a lot more tests and a lot more logic should be created to cover these cases.

Playing the game

An other option to get to the state that we want to test is by implementing claimFields and play the game until we reach the state we need. But if we'd do that, we'd be testing both claimFields and status. I think it's better to separate these tests so they're not depending on each other.

Seed constructor

So actually, the first solution of having a constructor to accept some fields is perfect, except for the API being public part of course!

Luckily there is something that we can do about this. We can use the meta package. The description of the package is as follows:

This package defines annotations that can be used by the tools that are shipped with the Dart SDK.

One of those annotations can be used to solve our problem. I'm talking about the @visibleForTesting annotation. By annotating a public property, method or constructor we tell Dart it should only only be... indeed... available for testing.

This way we don't touch the public API so we don't add unnecessary options for the consumer to worry about and don't create more test work for ourselves.

To use this package, simply add it to the pubspec.yaml.

Adding the constructor

What we will do is create another named constructor that we can use to seed a value for fields.

This is going to change the internals of our TicTacToeGameState quite a bit since we now have to store these seeded fields as well. When we apply this change it will look like this:

class TicTacToeGameState {
  final List<Player?> fields;

  TicTacToeGameState()
      : fields = const [null, null, null, null, null, null, null, null, null];

  @visibleForTesting
  TicTacToeGameState.seed({required this.fields});

  Status get status => Status.p1Turn;

  TicTacToeGameState claimField(int index) => throw 'not implemented';
}

As you can see, we've changed the fields getter into a final (read-only) property. Underneath you'll see not one, but two constructors. The first constructor replaces the default constructor. We need to make it explicit because we have to initialize our new fields property.

The second constructor is our new seed constructor which accepts a value for fields and is annotated with the @visibleForTesting annotation.

Sanity check

After such a HUGE refactor, it's good to check if we haven't broken anything. Our goal was to not impact the public API after all. So let's run all our tests:

$ dart test
00:00 +1 -1: changing the returned list should not alter the inner state [E]
  Unsupported operation: Cannot modify an unmodifiable list
  dart:_internal             UnmodifiableListMixin.[]=
  test/test_test.dart 36:23  main.<fn>

00:00 +2 -1: Some tests failed.

Oh, one of our tests is failing! The test that now fails is the one we added to prevent the possibility of editing the internal state, AKA the fields. It tells us that it tried to modify it. It didn't work since the List is unmodifiable (because it's const), so it threw an error.

Returning a copy of fields

Our previous implementation was a simple getter that just returned a new List every time it got called. Now we're returning the fields that are stored. To pass the test we should again return a List that can be modified but doesn't change the internal state.

So all we need to do is return a copy:

class TicTacToeGameState {
  final List<Player?> _fields;

  TicTacToeGameState()
      : _fields = const [null, null, null, null, null, null, null, null, null];

  @visibleForTesting
  TicTacToeGameState.seed({required List<Player?> fields}) : _fields = fields;

  Status get status => Status.p1Turn;

  List<Player?> get fields => List.from(_fields);

  TicTacToeGameState claimField(int index) => throw 'not implemented';
}

To be able to return a copy we reintroduced the fields getter. The fields property we've turned into a private property (recognizable by the _). The constructors now set this private property (_fields) and the fields getter returns a copy every time it gets called.

Now we run the test once more:

$ dart test
00:00 +3: All tests passed!

Testing all possible outcomes

With our new .seed constructor we are able to write the tests for the other outcomes of status. So let's get on with the next one!

You're up, player 2!

Let's now create a test that checks for the turn of player 2. So we basically are going to cover the following rule that we've written down during our preparation.

Players, in turn, claim a field by marking it (usually 'X' for player 1 and 'O' for player 2).

Creating the test

Tic Tac Toe is turn-based, which simply means the players 1 and 2 alternate turns. So to test this we should create a situation in which player 2 has to make a move. The easiest test would be to create a situation where only player 1 has made a move, like this:

Player two turn

Here player 1 has claimed the top left field. Now it's player 2's turn to make the next claim. Let's create this same situation as a test in our status group:

test(
  'should return p2Turn',
  () {
    final testFields = [
      Player.one,
      null,
      null,
      null,
      null,
      null,
      null,
      null,
      null
    ];
    final gameState = TicTacToeGameState.seeded(fields: testFields);

    expect(
      gameState.status,
      Status.p2Turn,
    );
  },
);

This test is pretty similar to the first test we've created for status. Only this time we use our .seed constructor to pass in a List of fields. The first entry of this List is set to Player.one, because this is the top-left field:

Fields indexes

Running the test

Running the test now should result in a fail:

$ dart test --name="should return p2Turn"
00:01 +0 -1: test/test_test.dart: status should return p2Turn [E]                                                                                                              
  Expected: Status:<Status.p2Turn>
    Actual: Status:<Status.p1Turn>

  package:test_api          expect
  test/test_test.dart 74:9  main.<fn>.<fn>

00:01 +0 -1: Some tests failed.

The test fails because it expects a .p2Turn but the actual value is .p1Turn.

Passing the test

Let's quickly resolve this by updating the .status getter!

Status get status => _fields[0] == Player.one ? Status.p2Turn : Status.p1Turn;

We add a simple check if the first field has been claimed by player 1 and return .p2Turn if that's the case, else, return .p1Turn.

$ dart test --name="should return p2Turn"
00:01 +1: All tests passed!

And that's it. We've satisfied another test.

Writing proper implementations

Okay, I agree, that was kind of lame. If we went on like this we'd end up with writing tests for all possible states, and then solving them almost hard-coded. This kind of repetitive work is not what we programmers do, right?

What I like to do in this situation is to write multiple tests for the same rule with different situations. So instead of testing one situation where it's player 2's turn, we can write three tests for example. Then we will try to solve this rule (so all tests at once) with a proper solution, instead of hard-coding them.

Picking two more situations

So to do this we will create two more situations in which player 2 is allowed to make a move. I've selected the following two:

Schermafbeelding 2022-08-04 om 21.00.40.png

Schermafbeelding 2022-08-04 om 21.02.19.png

In both cases player 1 was the last player that made a move. The game is also not yet finished (no one has claimed three fields in a row and not all fields have been claimed). This means that it's up to player 2 to claim the next field.

So let's turn these situations into tests.

Adding the tests

Adding these new tests is easy. We simply copy-paste the previous one, update the List we seed into our new .seed constructor and we're done!

We will end up with a group for the status getter looking like this:

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.seeded(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.seeded(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.seeded(fields: testFields);

          expect(
            gameState.status,
            Status.p2Turn,
          );
        },
      );
    },
  );

Great, let's run the tests!

Running all the tests for player 2's turn.

As you might have noticed is that we did not change the names of the new tests. This allows us to run all these tests at once! Let's do it:

$ status should return p2Turn [E]                                                                                                              
  Expected: Status:<Status.p2Turn>
    Actual: Status:<Status.p1Turn>

  package:test_api           expect
  test/test_test.dart 122:11  main.<fn>.<fn>

00:00 +2 -1: Some tests failed.

You'll see that all the three tests ran and one of them failed. So we'll have to update the implementation to solve this.

Fixing the implementation

One of the reasons I like to create and solve multiple tests at once in these situations is that you might already discover some patterns when creating the test cases.

As you know, Tic Tac Toe is a turn-based game. While writing the tests for player 2's turn, you might have noticed that this is reflected by the fact that every time we seed a List, it will contain one more Player.one entry than Player.two entries. This is a clue we can use for the new implementation!

When we apply this, our status getter could look something like this:

Status get status {
  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;
}

We simply get the amount of fields claimed by each player and check if they are equal. If they are equal, it means that player 2 has made the last claim and thus it's player 1's turn. If they are not equal, it means that player 1 has claimed one more field then player 2, and thus it's player 2's turn.

Re-running the tests

Now if we re-run the tests you'll see that they'll pass:

$ dart test --name="should return p2Turn"
00:00 +3: All tests passed!

What's next?

I've showed you a way to make a class testable without relying on other functionality of the class that's being tested and without impacting the public API by using the @visibleForTesting annotation from the meta package. With it we added a new constructor that allows us to put the TicTacToeGameState in the state we need it to be for writing our tests.

We've used this to write tests for the status getter that should return .p2Turn when it's... player 2's turn.

But as you might have noticed we're copy-pasting quite a bit. Just these three simple tests we've added make up for 85 lines of code. This doesn't make the file very maintainable and readable.

In the next article I will show you how we can use our developer skills to improve our tests!

Next article: Using your dev skills for testing