Monday 9 July 2012

On Types and Tests

There's been a couple of posts recently (Confession of a Haskell Hacker and Unit Testing isn't enough) about types and tests. I thought I'd write down some of my half-baked thoughts about how I think about types and tests.

When I'm writing code, I tend to try to write them in a functional-style. Given some arguments, produce some output. Give the same arguments, get the same answer. This isn't possible everywhere in most applications, but I've found it to be applicable most of the time. Given I've got this function, I now need to test it. I like to think of each function as having a shape.

Let's consider a simple function that scores a Poker hand. Our first signature for this is:

// And so on.
  const int HEARTS = 1;
  const int KING = 10;

  int ScoreHand(PairOfInts[] cards) {}

If I was to visualize this function, I'd imagine something like this:


The values of possible input to this function are HUGE.  It's defined over every single possible pair of integers.  The only interesting area is the tiny red dot in the middle (not to scale) that represents the tiny amount of the possible input that it's possible to return a valid value.  As is already clear this isn't a good way to write a function - you can pass almost anything in, and the chances of getting a sensible response is slim.  The testing burden on this function is huge, whereas the type "burden" on the function is very low.

Let's try and improve things a bit.

   enum Suit { Hearts, Diamonds, Clubs, Spades };
   int ScoreHand(PairOfSuitAndAnInt[] cards) { /* reams of code */}   

This is better, the size of the red circle has increased a tiny amount, but there's still a huge amount of code that it can't (sensibly) produce a value for. This is known as a partial function. We have to test slightly less (no need to test that a Suit is valid), but we still have to consider a huge range of other options (what if there are six cards?).

What we're after is aligning the function so that for every value we can calculate a suitable score.

   enum Suit { Hearts, Diamonds, Clubs, Spades };
   enum Value { One, Two, Three, Four, Five, Six
                 Seven, Eight, Nine, Ten,
                 Jack, Queen, King, Ace };

   class Card {
     public final Suit suit;
     public final Value value;
     // And an appropriate constructor
   }

   int ScoreHand(Card a, Card b, Card c, Card d, Card e) { /* reams of code */}


Now I'm closer to total function. It guarantees (via the type system) to produce a sensible value for each and every input that's passed to it. (I'm just going to willfully ignore that Java/C# allows null values. Let's pretend it doesn't!). The big hole we have is that we don't check the uniqueness of the cards (e.g. I could ask for the score of five aces!).

If I visualize the function now, it's much (that's an exaggeration) more interesting.  Red defines the area that the function produces a sensible value for and the area around the outside is the inputs we don't provide a sensible output for!



Now I can go about testing this in a much nicer way. Traditional unit testing is existential quantification. Given this set of values, I expect this kind of output. It gives some assurance for some values. I'll need some of these tests just as a sanity check.

Property-based testing extends this. In property-based testing I can say things like given any input of the form needed by the function, the following holds. For example, I could write a test that said given 5 unique cards, if I compare them (with the output of the ScoreHand function) I'll lose against a Royal Flush (unless I also have a royal flush).   If you have a total function, Property-based testing allows universal quantification - you can specify an invariant that holds for all values of input.

You should use your type system (if you have one) to constrain the inputs to your functions as much as possible. The more constrained the types, the less you have to test (you still need both!). If you can define a total function, and a set of invariants that hold for all values then you have a incredibly powerful test-suite.