Using your dev skills for testing

Using your dev skills for testing

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

Schermafbeelding 2022-08-04 om 21.00.40.png

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:

tictactoe-draw.png

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:

tictactoe-p1win.png

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:

tictactoe-p2win.png

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:

Schermafbeelding 2022-08-18 om 20.26.41.png

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