Implementation-Inspired Tests

Test-Driven Development

Test-Driven Development (TDD), is a software development technique which can be used to create clean and well-tested software. The normal TDD workflow is to write a test, see it fail, write the production code to make the test pass, then clean up the result through refactoring. Thinking through tests first helps us explore our understanding of the requirements. It also helps us prioritize clean interfaces over implementation details, and it helps makes sure our code is actually testable. In the TDD workflow, the production code is continually expanded to become more sophisticated, handling more and more cases, while also being refactored to make it simpler and easier to understand. When done well, this process can produce astonishingly simple and effective implementations.

One of the big advantages of TDD is that it forces us to have tests for all the code we write, so we can refactor with confidence. Let’s review the rules of TDD as described by Robert C. Martin (Uncle Bob):

  1. You are not allowed to write any production code unless it is to make a failing unit test pass.
  2. You are not allowed to write any more of a unit test than is sufficient to fail; and compilation failures are failures.
  3. You are not allowed to write any more production code than is sufficient to pass the one failing unit test.

If these rules are strictly followed, they guarantee that every line of production code that is written has been tested, since the only reason you add production code is to satisfy a test. These rules also guarantee that the tests have been tested, since you’ve seen them initially fail. In addition, the small steps help keep everything simple. The usual approach is to write a test first, but sometimes it is difficult to come up with good test cases. We may understand the rules of a system, but that doesn’t mean we can easily come up with good examples within those rules off the top of our head. An experienced programmer may see the basic requirements and immediately start to envision an implementation that supports those requirements, even without knowing any specific examples. Having to throw away this vision and start thinking about test cases could be frustrating, and maybe even counterproductive.

The rules of TDD don’t specify how we come up with tests in the first place, so here’s a trick: we can write the code that we want to add to the production code, but not actually add it. For example, we can write it, but leave it commented out. We can then use the implementation to inspire our tests and uncomment the code as we find ways to test it.

Example Workflow

Let’s see how this applies to a simple example — the Bowling Game Kata. Here are the rules, as given on codingdojo.org:

  • Each game, or “line” of bowling, includes ten turns, or “frames” for the bowler.
  • In each frame, the bowler gets up to two tries to knock down all the pins.
  • If in two tries, he fails to knock them all down, his score for that frame is the total number of pins knocked down in his two tries.
  • If in two tries he knocks them all down, this is called a “spare” and his score for the frame is ten plus the number of pins knocked down on his next throw (in his next turn).
  • If on his first try in the frame he knocks down all the pins, this is called a “strike”. His turn is over, and his score for the frame is ten plus the simple total of the pins knocked down in his next two rolls.
  • If he gets a spare or strike in the last (tenth) frame, the bowler gets to throw one or two more bonus balls, respectively. These bonus throws are taken as part of the same turn. If the bonus throws knock down all the pins, the process does not repeat: the bonus throws are only used to calculate the score of the final frame.
  • The game score is the total of all frame scores.

Experienced programmers might look at these rules and start forming code in their head. They see “the score of the game is the sum of the individual scores for each frame” and think “that’s a loop”. Likewise, they see “If on his first try in the frame he knocks down all the pins” and think “that’s an if statement”.

The typical non-TDD approach is to write the code first. Even the best programmers will probably not write every character correctly the first time though. They’ll write a little code, make adjustments, and write a little more code. They’ll keep doing this until the code “feels right”. At this point, they will do some manual testing to try to make sure they haven’t made some silly mistake, and if those tests work, they’ll call it done.

To use TDD, we really just have to make one simple change; write the code, but leave it commented out. Writing comments doesn’t violate the rules of TDD because it doesn’t change the behavior of the production code.

Let’s say we’ve come up with an implementation like this (in C++):

//#include 

//int calculate_score(const std::vector &pins_hit)
//{
//    int score = 0;
//    int roll = 0;
//    for (int frame = 0; frame<10; ++frame) {
//        if (pins_hit[roll]==10) {
//            score += 10 + pins_hit[roll+1] + pins_hit[roll+2];
//            roll += 1;
//        }
//        else if (pins_hit[roll]+pins_hit[roll+1]==10) {
//            score += 10 + pins_hit[roll+2];
//            roll += 2;
//        }
//        else {
//            score += pins_hit[roll] + pins_hit[roll+1];
//            roll += 2;
//        }
//    }
//    return score;
//}

To bring this code into production, we need to look at a line and say to ourselves “What test would show that this line of code is necessary?”

We can start by having some way to show that the definition itself is necessary.

assert(
    0==calculate_score({0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0})
);

Note that we are providing a valid test case. We could have forced the production code to be added by using an empty sequence of rolls, but our code is not supposed to handle partial scoring, so we shouldn’t be testing for that.

Compiling the test produces

error: ‘calculate_score’ was not declared in this scope

This forces us to uncomment a few lines; first the declaration, then the braces for the main body of the function, then the return statement, then the declaration of calculate_score, then the #include. This is the minimal amount of code we can uncomment such that it compiles without warnings. We also leave the parameter name commented out to avoid a warning.

  #include <vector>

  int calculate_score(const std::vector & /*pins_hit*/)
  {
      int score = 0;
//    int roll = 0;
//    for (int frame = 0; frame<10; ++frame) {
//        if (pins_hit[roll]==10) {
//            score += 10 + pins_hit[roll+1] + pins_hit[roll+2];
//            roll += 1;
//        }
//        else if (pins_hit[roll]+pins_hit[roll+1]==10) {
//            score += 10 + pins_hit[roll+2];
//            roll += 2;
//        }
//        else {
//            score += pins_hit[roll] + pins_hit[roll+1];
//            roll += 2;
//        }
//    }
      return score;
  }

In addition to compiling, our test passes. This is a good first step, but many of the lines are still commented out. We need another test that will force us to uncomment something.

The code to handle strikes looks like an easy case. Let’s add a test for a perfect game:

assert(
    300==calculate_score({10,10,10,10,10,10,10,10,10,10,10,10})
);

This fails, so now we have a reason to uncomment more lines. If we uncomment the minimal number of lines to get our tests to pass, we get this:

  #include <vector>

  int calculate_score(const std::vector & /*pins_hit*/)
  {
      int score = 0;
      int roll = 0;
      for (int frame = 0; frame<10; ++frame) {
//        if (pins_hit[roll]==10) {
              score += 10 + pins_hit[roll+1] + pins_hit[roll+2];
//            roll += 1;
//        }
//        else if (pins_hit[roll]+pins_hit[roll+1]==10) {
//            score += 10 + pins_hit[roll+2];
//            roll += 2;
//        }
//        else {
//            score += pins_hit[roll] + pins_hit[roll+1];
//            roll += 2;
//        }
      }
      return score;
  }

Having the roll += 1 line commented out is concerning. It clearly isn’t “right”. Our test is passing, but for the wrong reason. We’re just looking at the first three rolls over and over again. The first three rolls just happen to be the same as all the others. What does this mean? We need more tests! We can force the “roll += 1” line to be added by copying our previous test and making one small change:

assert(
    299==calculate_score({10,10,10,10,10,10,10,10,10,10,10,9})
);

This test initially fails, which gives us a reason to uncomment the “roll += 1” line. If we continue this process until all the lines are uncommented, we’ll end up with a set of tests like these:

assert(
    0==calculate_score({0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0})
);
assert(
    300==calculate_score({10,10,10,10,10,10,10,10,10,10,10,10})
);
assert(
    299==calculate_score({10,10,10,10,10,10,10,10,10,10,10, 9})
);
assert(
    30==calculate_score({1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2})
);
assert(
    29==calculate_score({1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,1})
);
assert(
    11==calculate_score({0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,9,1,1})
);
assert(
    12==calculate_score({9,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0})
);

For all these tests to pass, we must have every line of our original implementation. In each case, we’ve come up with a test that is easy to calculate in our head. There’s no need to have a test case more complicated than what is necessary to pull some of the production code out of the comments.

Now that we have production code with good test coverage, we are free to refactor it. We want to eliminate duplication and make the code more readable if possible. There are various issues with the code that we could address. For example, the use of 10 as a magic number for the number of pins that need to be hit for a strike could be replaced by a named constant. Even better, we could replace if (pins_hit[roll]==10) with if (isStrike(pins_hit,roll)). We can be confident that we can do these refactorings without fear of breaking our implementation because of our tests.

Conclusion

If coming up with the implementation is easier than coming up with tests, then writing the implementation first may be an effective way to get started, but by leaving that code commented out and using tests to force it to be uncommented, you’ll at least gain some of the benefits of TDD.