Wednesday, November 19, 2008

Test pattern

File this under note to self. Say you have a method to test, and inside it looks something like:

public class Foo
{
public bool Bar()
{
return A && B && C && D == "https";
}
}

Meaning, your method has several dimensions of possible inputs, and only one or a handful of acceptable states. Obviously, if the function really is as simple as that, you might not even want a test; static inspection might suffice. But suppose that the evaluation of A, B, and C is a little more complex or expensive, and suppose that there are, say, 3 different acceptable combinations among the full permuation of inputs.

Further suppose that A..D are each enumerable domains. (The actual use case that made me think of this was something like: A is "whether a page requires SSL", B is "the user is authenticated", C is "a certain control is visible", and D is "the protocol on the page request".)

What should the test look like?

The code that began to test-drive the implementation started out like this:

[Test]
public void Bar()
{
Foo foo = new Foo() { A = true, B = true, C = true, D = Uri.Https };
Assert.That(foo.Bar(), Is.True);
// cases 2 and 3 not shown but expect true

// everything else expects false
foo.A = false;
Assert.That(foo.Bar(), Is.False);
// tedious and brittle cut-and-paste looms if not careful...
}

This is the sort of situation you get into with TDD and Simplest Thing That Could Possibly Work. Having plucked the low-hanging fruit in the first few passes, you then need to cover something like "all the other situations". You're tempted to write something like:

[Test]
public void Bar()
{
foreach(bool a in new[] {true, false})
foreach(bool b in new[] {true, false})
foreach(bool c in new[] {true, false})
foreach(string scheme in new[] {Uri.UriSchemeHttp, Uri.UriSchemeHttps, Uri.UriSchemeFile})
{
Foo foo = new Foo() { A = a, B = a, C = a, D = scheme};
Assert.That(foo.Bar(), Is.EqualTo(a && b && c && d == Uri.Https));
// okay, this only covers one of the three acceptance cases; you get the idea, though
}
}

In other words, your verification looks suspiciously like your implementation. Nine times out of ten, that will work. But every so often you discover that you've overlooked some aspect of the problem, and your mirror-image test code didn't identify the discrepancy because it was so similar to the implementation that both had the same bug. (For example: maybe the evaluation of A, B, and C can have side effects that create a subtle a temporal dependency in the order of evaluation, and by duplicating the SUT logic in the test, you never exercise alternative paths.) In general, I feel much safer if the test code takes a different route over the problem space than the SUT does.

You could go table-driven, but then you've got a table to maintain, and how do you know the table is correct? (Read: exhaustive.) Really, you want to codegen the table, which is why I actually like the nested foreach() blocks that iterate over explicit sets in the second version. The loops are clear, terse, and exhaustive. I'd like to keep that portion of the structure while having something that is logically equivalent without being codewise identical.

So, here's the pattern I prefer:

[Test]
public void Bar()
{
// explicitly test each of the small number of outliers
Foo foo = new Foo() { A = true, B = true, C = true, D = Uri.UriSchemeHttp };
Assert.That(foo.Bar(), Is.True);
// repeat for the two other affirmative cases

int trues;
foreach(bool a in new[] {true, false})
foreach(bool b in new[] {true, false})
foreach(bool c in new[] {true, false})
foreach(string scheme in new[] {Uri.UriSchemeHttp, Uri.UriSchemeHttps, Uri.UriSchemeFile})
{
Foo foo = new Foo() { A = a, B = a, C = a, D = scheme};
if(foo.Bar()) trues++;
}
Assert.That(trues, Is.EqualTo(3));
}

Terse? Check. Exhaustive? Check. Sufficiently different? Check. It seems unlikely that, if I refactor this code six weeks from now, I'd make a mental mistake on the implementation that would easily transfer to the test as well.

As they say in math, the proof is by counting.

No comments: