Handling consumer mistakes

Handling consumer mistakes

When a consumer of our package makes a mistake we have to inform them about it so they can handle the mistake in their code. In this article I will show you how you can do that.

This article is the last in the Introduction to TDD series in which we're creating a package containing logic for the game Tic Tac Toe.

In the previous article we've finished the last getter of our TicTacToeGameState class. In this article we will focus on the only function this class has, which allows consumers to actually play the game by claiming fields.

Claiming fields

The function that we're going to focus on is the claimField function. As designed in an earlier article, the consumer can use this function to (try to) claim a field, for the player that has the move, and receive the new state.

Expected behavior

Previously we've only worked with fields/getters. This time we're working on function that the consumer can call:

TicTacToeGameState claimField(int field);

The expected behavior is that the consumer will call this function with a number which correspondents to a field that is available and that we can return a new TicTacToeGameState as a result.

To be honest, that's probably the easiest part. I think in most cases the expected behavior is easier then the unexpected behaviors and that's why we tend to forget about them.

But we will not forget, oh no, not us.

Unexpected behavior

Let's take a moment to think about unexpected behaviors, like which things can go wrong and how do we handle them.

Invalid field

As mentioned earlier, we're expecting the the consumer to call the claimField function with a valid field. A valid field is a number between 0 and 8 (because there are only nine fields), and one that hasn't been claimed yet. So what if the consumer tries to claim an invalid field? How do we let them know?

Game already over

This one should also sound pretty obvious. But fields should only be claimed while the game is not yet over. So what do we do if the consumer tries to claim a field while the game has already ended?

Dealing with mistakes

It's important that we let the consumer know they've made a mistake so they can deal with it.

One thing we could do is returning the current TicTacToeGameState again. The consumer can then see nothing has changed and assume something went wrong, right? Sure, but it could easily be missed and it doesn't really say anything about what went wrong now does it?

Exceptions

The other thing that we could do, which is something we're all familiar with, is throwing an Exception. An Exception interrupts the execution of code when not handled properly. This will surely get the consumers attention!

The 'nice' thing about Dart is that you can throw anything you'd like as if it's an Exception, we did this in an earlier article just to add some placeholder code to keep the compiler satisfied while the real implementation was missing. Actually, we're still doing this:

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

While we could just be throwing Strings around with messages, I prefer to make some dedicated Exception class(es) which are easier to handle for our consumers.

Designing our own Exceptions

Creating an Exception is easy. Specially since in Dart you can throw anything. However, we will implement the already existing Exception class.

So what is our exception class going to look like? There are several ways to do this, and it's up to you which option you prefer. This is my personal preference:

/// A generic exception for the TicTacToe package
abstract class TicTacToeException extends Equatable implements Exception {
  String get message;

  @override
  String toString() => message;

  @override
  List<Object?> get props => [message];
}

/// An exception that's thrown when [claimField] is wrongly called.
abstract class InvalidClaimException extends TicTacToeException {}

class ClaimOutOfBoundException extends InvalidClaimException {
  // The field that the consumer tried to claim
  final int field;

  ClaimOutOfBoundException(this.field);

  @override
  String get message =>
      'The field that was being claimed ($field) is out of bounds, only 0 - 8 is allowed!';

  @override
  List<Object?> get props => [...super.props, field];
}

class FieldAlreadyClaimedException extends InvalidClaimException {
  // The field that the consumer tried to claim
  final int field;

  FieldAlreadyClaimedException(this.field);

  @override
  String get message =>
      'The field that was being claimed ($field) has already been claimed before!';

  @override
  List<Object?> get props => [...super.props, field];
}

class GameAlreadyOverException extends InvalidClaimException {
  @override
  String get message =>
      'Fields cannot be claimed on a TicTacToeGameState that is already finished!';
}

I've started with a generic exception for our package called TicTacToeException. Then I've added an InvalidClaimException that points to the action the user is doing, which is claiming a field. Then I've created three more exceptions for each distinct mistake the consumer can make. I've also added Equatable for making comparing the exceptions more easy, which you'll see soon.

It is a bit of work, but your consumers have a lot of control over how to handle your Exceptions in their code. They could perform different actions based on the different Exceptions more easily.

Note that you'd probably come up with exceptions along the way when implementing, but since I felt like the explanation could become a bit messy, I've now created them before hand.

Implementing claimFields

Now that we've got all our TicTacToeExceptions in place, we can start working on the claimField function. So let's start with the unexpected behaviors one-by-one and work our way towards the expected behavior.

Field out of bounds

The first thing we want to cover will be the mistake the consumer can make by trying to claim a field that's not even within the range of the fields. We've made the ClaimOutOfBoundException for this. We expect this to be thrown when the consumer claims a field (number) that's not in range of 0 - 8. Let's write the test:

group(
  'claimField',
  () {
    group(
      'TicTacToeException',
      () {
        final testCases = [
          ClaimFieldTestCase('---|---|---', -1, ClaimOutOfBoundException(-1)),
          ClaimFieldTestCase('---|-X-|---', 9, ClaimOutOfBoundException(9)),
        ];

        for (var testCase in testCases) {
          test(
            'Trying to claim field \'${testCase.fieldToClaim}\' on ${testCase.initialState} should result in a ${testCase.expected}',
            () {
              final gameState =
                  ticTacToeGameStateFromString(testCase.initialState);

              expect(
                () => gameState.claimField(testCase.fieldToClaim),
                throwsA(testCase.expected),
              );
            },
          );
        }
      },
    );
  },
);

As you can see in the above code are we creating two groups. First we create the group for the function itself, and then a group were we will test our exceptions. We also went straight ahead with using our loop trick to cover more cases at once.

Because we used Equatable earlier it is very easy to compare our exceptions. Equatable comes from the equatable package.

Because our testCases require more properties we cannot use a map this time. So for that we need a small helper class:

class ClaimFieldTestCase<T> {
  final String initialState;
  final int fieldToClaim;
  final T expected;

  const ClaimFieldTestCase(
    this.initialState,
    this.fieldToClaim,
    this.expected,
  );
}

It contains the initial state, the field that we're claiming, and a field for the expected outcome. We've used a Type parameter for this so it's reusable. More on that later.

Since we haven't changed our implementation we can expect the test to fail, which it does:

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

We can easily fix this by updating the implementation to throw our new ClaimOutOfBoundException instead of the 'not implemented' String:

TicTacToeGameState claimField(int index) =>
      throw ClaimOutOfBoundException(index);

When we now run the tests again, you'll see that they pass:

00:00 +2: All tests passed!

On to the next!

Field already claimed

The next mistake we're going to cover is the one where the consumer tries to claim a field that has already been claimed. We can easily add these tests now:

final testCases = [
  ClaimFieldTestCase('---|---|---', -1, ClaimOutOfBoundException(-1)),
  ClaimFieldTestCase('---|-X-|---', 9, ClaimOutOfBoundException(9)),
  ClaimFieldTestCase('---|-X-|---', 4, FieldAlreadyClaimedException(4)), // new
  ClaimFieldTestCase('-O-|OXX|--X', 8, FieldAlreadyClaimedException(8)), // new
  ClaimFieldTestCase('XOO|-XX|O--', 2, FieldAlreadyClaimedException(2)), // new
];

Of course these new tests now fail as well:

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

So let's fix this with the least minimal code:

TicTacToeGameState claimField(int index) {
  if (index < 0 || index > 8) throw ClaimOutOfBoundException(index);
  throw FieldAlreadyClaimedException(index);
}

And once again we've made it pass:

00:00 +5: All tests passed!

Onto the next and last test for these exceptions!

Game already over

The last Exception we are going to throw is the GameAlreadyOverException. We will throw this when the consumer tries to claim a field while the game is already over.

So let's borrow some states from the status getter tests that resulted in either a .draw, .p1Win or a .p2Win and call .claimField on them.

final testCases = [
  ClaimFieldTestCase('---|---|---', -1, ClaimOutOfBoundException(-1)),
  ClaimFieldTestCase('---|-X-|---', 9, ClaimOutOfBoundException(9)),
  ClaimFieldTestCase('---|-X-|---', 4, FieldAlreadyClaimedException(4)),
  ClaimFieldTestCase('-O-|OXX|--X', 8, FieldAlreadyClaimedException(8)),
  ClaimFieldTestCase('XOO|-XX|O--', 2, FieldAlreadyClaimedException(2)),
  ClaimFieldTestCase('O-X|--X|-OX', 3, GameAlreadyOverException()), // new
  ClaimFieldTestCase('OOO|XXO|XX-', 4, GameAlreadyOverException()), // new
  ClaimFieldTestCase('OXX|XOO|XOX', 5, GameAlreadyOverException()), // new
];

In the first added test we try to claim an empty field while the game is already over. This obviously should throw our GameAlreadyOverException. However, in the second we claim a field that has already been claimed, while the game is also over. So here we've decided that the GameAlreadyOverException has priority over the FieldAlreadyClaimedException, because that's the one we're expecting.

When running the tests again we'll see they fail:

00:01 +5 -3: Some tests failed.

So let's make them pass!

TicTacToeGameState claimField(int index) {
  if ([Status.draw, Status.p1Win, Status.p2Win].contains(status)) {
    throw GameAlreadyOverException();
  }
  if (index < 0 || index > 8) throw ClaimOutOfBoundException(index);
  throw FieldAlreadyClaimedException(index);
}

Since the GameAlreadyOverException has a high priority we've added the check for it at the start of the function. So if the game is already over, this will throw first.

Running the tests again will show that they'll pass:

00:00 +8: All tests passed!

And this concludes our Exceptions and we can work on the last piece of the 'puzzle', the actual claiming of the field!

Returning a new state

So now that we've taken care of handling the mistakes, we can finish up the claimField function by actually returning a new TicTacToeGameState when a field is successfully claimed.

So let's start by writing the tests for them:

group(
  'New TicTacToeGameState',
  () {
    final testCases = [
      ClaimFieldTestCase('---|---|---', 0, 'X--|---|---'),
      ClaimFieldTestCase('-O-|XOX|O-X', 2, '-OX|XOX|O-X'),
      ClaimFieldTestCase('X-O|-X-|---', 7, 'X-O|-X-|-O-'),
    ];

    for (var testCase in testCases) {
      final gameState =
          ticTacToeGameStateFromString(testCase.initialState);

      final expectedGameState =
          ticTacToeGameStateFromString(testCase.expected);

      final actualGameState = gameState.claimField(testCase.fieldToClaim);
      test(
        'Calling claimField on $gameState with field ${testCase.fieldToClaim} should result in $expectedGameState',
        () {
          expect(actualGameState, expectedGameState);
        },
      );
    }
  },
);

We can reuse the ClaimFieldTestCase for these tests, but we will create a separate group for these tests. This is because the result we are expecting now won't be thrown. Instead it will just be a comparison between objects.

And again these tests will fail:

00:00 +8 -3: Some tests failed.

As stated before, implementing the expected behavior is often easier then handling the unexpected behavior:

TicTacToeGameState claimField(int index) {
  if ([Status.draw, Status.p1Win, Status.p2Win].contains(status)) {
    throw GameAlreadyOverException();
  }
  if (index < 0 || index > 8) throw ClaimOutOfBoundException(index);
  if (_fields[index] != null) throw FieldAlreadyClaimedException(index);

  // Expected behavior
  final newFields = [..._fields]..[index] =
      status == Status.p1Turn ? Player.one : Player.two;
  return TicTacToeGameState.seed(fields: newFields);
}

So what's going on is the following. We start with making a copy of the current fields by doing [...fields]. The double dot (..) causes this new list to be returned. By doing so we can immediately update this list by accessing the index [index] and setting either Player.one or Player.two, based on who has the current turn.

We have to do one more thing to make our tests work. Because we want to compare objects we have to also add Equatable to our TicTacToeGameState:

class TicTacToeGameState extends Equatable {
  final List<Player?> _fields;

  ...

  @override
  List<Object?> get props => [fields];
}

Now let's run all the tests for one last time!:

00:00 +29: All tests passed!

And yes they do, all of the tests have passed, which means we've completed our package's functionality!!!

Conclusion

In these series we've worked towards creating our own package containing the logic for the game Tic Tac Toe. I've showed you my approaches when applying TDD and together we went through them step-by-step until we ended up with a package others can use to create their own TicTacToe game!

I hope you've enjoyed this series! Please leave a like and/or comment if you did! Thank you!