Shameless green: TDD in Go
The goal right now is not to get the perfect answer but to pass the test. We’ll make our sacrifice at the altar of truth and beauty later.
—Kent Beck, “Test-Driven Development by Example”
This is the second in a three-part series, extracted from my book The Power of Go: Tests, about test-driven development (TDD) in Go—in other words, building software in Go, guided by tests.
- Programming with confidence
- Shameless green
- Refactoring (coming soon)
In Part 1, we started writing a retro text adventure in Go, guided by tests. The first test we wrote was for a function ListItems
, which shows the player what items are present in their current location:
You can see here a battery, a key, and a tourist map.
To validate this test, we deliberately wrote a buggy version of ListItems
that always just returns the empty string. This should fail any self-respecting test, and so it does. Now let’s see if we can make it pass.
Here’s one rough first attempt:
func ListItems(items []string) string {
:= "You can see here"
result += strings.Join(items, ", ")
result += "."
result return result
}
I really didn’t think too hard about this, and I’m sure it shows. That’s all right, because we’re not aiming to produce elegant, readable, or efficient code at this stage. Trying to write code from scratch that’s both correct and elegant is pretty hard. Let’s not stack the odds against ourselves by trying to multi-task here.
In fact, the only thing we care about right now is getting the code correct. Once we have that, we can always tidy it up later. On the other hand, there’s no point trying to beautify code that doesn’t work yet.
Let’s see how it performs against the test:
--- FAIL: TestListItems_GivesCorrectResultForInput (0.00s)
game_test.go:18: want "You can see here a battery, a key, and
a tourist map.", got "You can see herea battery, a key, a
tourist map."
Well, that looks close, but clearly not exactly right. In fact, we can improve the test a little bit here, to give us a more helpful failure message.
Using cmp.Diff
to compare results
Since part of the result is correct, but part isn’t, we’d actually like the test to report the difference between want
and got
, not just print both of them out.
There’s a useful third-party package for this, go-cmp
. We can use its Diff
function to print just the differences between the two strings. Here’s what that looks like in the test:
func TestListItems_GivesCorrectResultForInput(t *testing.T) {
.Parallel()
t:= []string{
input "a battery",
"a key",
"a tourist map",
}
:= "You can see here a battery, a key, and a tourist map."
want := game.ListItems(input)
got if want != got {
.Error(cmp.Diff(want, got))
t}
}
Here’s the result:
--- FAIL: TestListItems_GivesCorrectResultForInput (0.00s)
game_test.go:20: strings.Join({
"You can see here",
- " ",
"a battery, a key,",
- " and",
" a tourist map.",
}, "")
When two strings differ, cmp.Diff
shows which parts are the same, which parts are only in the first string, and which are only in the second string.
According to this output, the first part of the two strings is the same:
"You can see here",
But now comes some text that’s only in the first string (want
). It’s preceded by a minus sign, to indicate that it’s missing from the second string, and the exact text is just a space, shown in quotes:
- " ",
So that’s one thing that’s wrong with ListItems
, as detected by the test. It’s not including a space between the word “here” and the first item.
The next part, though, ListItems
got right, because it’s the same in both want
and got
:
"a battery, a key,",
Unfortunately, there’s something else present in want
that is missing from got
:
- " and",
We forgot to include the final “and” before the last item. The two strings are otherwise identical at the end:
" a tourist map.",
You can see why it’s helpful to show the difference between want
and got
: instead of a simple pass/fail test, we can see how close we’re getting to the correct result. And if the result were very long, the diff would make it easy to pick out which parts of it weren’t what we expected.
Let’s make some tweaks to ListItems
now to address the problems we detected:
func ListItems(items []string) string {
:= "You can see here "
result += strings.Join(items[:len(items)-1], ", ")
result += ", and "
result += items[len(items)-1]
result += "."
result return result
}
A bit ugly, but who cares? As we saw earlier, we’re not trying to write beautiful code at this point, only correct code. This approach has been aptly named “Shameless Green”:
The most immediately apparent quality of Shameless Green code is how very simple it is. There’s nothing tricky here. The code is gratifyingly easy to comprehend. Not only that, despite its lack of complexity this solution does extremely well.
—Sandi Metz & Katrina Owen, “99 Bottles of OOP: A Practical Guide to Object-Oriented Design”
In other words, shameless green code passes the tests in the simplest, quickest, and most easily understandable way possible. That kind of solution may not be the best, as we’ve said, but it may well be good enough, at least for now. If we suddenly had to drop everything and ship right now, we could grit our teeth and ship this.
So does ListItems
work now? Tests point to yes:
go test
PASS
ok game 0.160s
The test is passing, which means that ListItems
is behaving correctly. That is to say, it’s doing what we asked of it, which is to format a list of three items in a pleasing way.
New behaviour? New test.
Are we asking enough of ListItems
with this test? Will it be useful in the actual game code? If the player is in a room with exactly three items, we can have some confidence that ListItems
will format them the right way. And four or more items will probably be fine too.
What about just two items, though? From looking at the code, I’m not sure. It might work, or it might do something silly. Thinking about the case of one item, though, I can see right away that the result won’t make sense.
The result of formatting a slice of no items clearly won’t make sense either. So what should we do? We could add some code to ListItems
to handle these cases, and that’s what many programmers would do in this situation.
But hold up. If we go ahead and make that change, then how will we know that we got it right? We can at least have some confidence that we won’t break the formatting for three or more items, since the test would start failing if that happened. But we won’t have any way to know if our new code correctly formats two, one, or zero items.
We started out by saying we have a specific job that we want ListItems
to do, and we defined it carefully in advance by writing the test. ListItems
now does that job, since it passes the test.
If we’re now deciding that, on reflection, we want ListItems
to do more, then that’s perfectly all right. We’re allowed to have new ideas while we’re programming: indeed, it would be a shame if we didn’t.
But let’s adopt the rule “new behaviour, new test”. Every time we think of a new behaviour we want, we have to write a test for it, or at least extend an existing passing test so that it fails for the case we’re interested in.
That way, we’ll be forced to get our ideas absolutely clear before we start coding, just like with the first version of ListItems
. And we’ll also know when we’ve written enough code, because the test will start passing.
This is another point that I’ve found my students sometimes have difficulty with. Often, the more experienced a programmer they are, the more trouble it gives them. They’re so used to just going ahead and writing code to solve the problem that it’s hard for them to insert an extra step in the process: writing a new test.
Even when they’ve written a function test-first to start with, the temptation is then to start extending the behaviour of that function, without pausing to extend the test. In that case, just saying “New behaviour, new test” is usually enough to jog their memory. But it can take a while to thoroughly establish this new habit, so if you have trouble at first, you’re not alone. Stick at it.
Test cases
We could write some new test functions, one for each case that we want to check, but that seems a bit wasteful. After all, each test is going to do exactly the same thing: call ListItems
with some input, and check the result against expectations.
Any time we want to do the same operation repeatedly, just with different data each time, we can express this idea using a loop. In Go, we usually use the range
operator to loop over some slice of data.
What data would make sense here? Well, this is clearly a slice of test cases, so what’s the best data structure to use for each case?
Each case here consists of two pieces of data: the strings to pass to ListItems
, and the expected result. Or, to put it another way, input
and want
, just like we have in our existing test.
One of the nice things about Go is that any time we want to group some related bits of data into a single value like this, we can just define some arbitrary struct
type for it. Let’s call it testCase
:
func TestListItems_GivesCorrectResultForInput(t *testing.T) {
type testCase struct {
[]string
input string
want }
...
How can we refactor our existing test to use the new testCase
struct type? Well, let’s start by creating a slice of testCase
values with just one element: the three-item case we already have.
...
:= []testCase{
cases {
: []string{
input"a battery",
"a key",
"a tourist map",
},
:
want"You can see here a battery, a key, and a tourist map.",
},
}
...
What’s next? We need to loop over this slice of cases using range
, and for each case, we want to pass its input
value to ListItems
and compare the result with its want
value.
...
for _, tc := range cases {
:= game.ListItems(tc.input)
got if tc.want != got {
.Error(cmp.Diff(tc.want, got))
t}
}
This looks very similar to the test we started with, except that most of the test body has moved inside this loop. That makes sense, because we’re doing exactly the same thing in the test, but now we can do it repeatedly for multiple cases.
This is commonly called a table test, because it checks the behaviour of the system given a table of different inputs and expected results. Here’s what it looks like when we put it all together:
func TestListItems_GivesCorrectResultForInput(t *testing.T) {
type testCase struct {
[]string
input string
want }
:= []testCase{
cases {
: []string{
input"a battery",
"a key",
"a tourist map",
},
:
want"You can see here a battery, a key, and a tourist map.",
},
}
for _, tc := range cases {
:= game.ListItems(tc.input)
got if tc.want != got {
.Error(cmp.Diff(tc.want, got))
t}
}
}
First, let’s make sure we didn’t get anything wrong in this refactoring. The test should still pass, since it’s still only testing our original three-item case:
PASS
ok game 0.222s
Great. Now comes the payoff: we can easily add more cases, by inserting extra elements in the cases
slice.
Adding cases one at a time
What new test cases should we add at this stage? We could add lots of cases at once, but since we feel pretty sure they’ll all fail, there’s no point in that.
Instead, let’s treat each case as describing a new behaviour, and tackle one of them at a time. For example, there’s a certain way the system should behave when given two inputs instead of three, and it’s distinct from the three-item case. We’ll need some special logic for it.
So let’s add a single new case that supplies two items:
{
: []string{
input"a battery",
"a key",
},
: "You can see here a battery and a key.",
want},
The value of want
is up to us, of course: what we want to happen in this case is a product design decision. This is what I’ve decided I want, with my game designer hat on, so let’s see what ListItems
actually does:
--- FAIL: TestListItems_GivesCorrectResultForInput (0.00s)
game_test.go:36: strings.Join({
"You can see here a battery",
+ ",",
" and a key.",
}, "")
Not bad, but not perfect. It’s inserting a comma after “battery” that shouldn’t be there. Next time, we’ll see what we can do to fix that. We’ll add the remaining test cases and behaviours, and we’ll learn how to play an exciting game called “Red, Green, Refactor”. Don’t miss it!