Shameless green: TDD in Go

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.

  1. Programming with confidence
  2. Shameless green
  3. 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 {
    result := "You can see here"
    result += strings.Join(items, ", ")
    result += "."
    return result
}

(Listing game/1)

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) {
    t.Parallel()
    input := []string{
        "a battery",
        "a key",
        "a tourist map",
    }
    want := "You can see here a battery, a key, and a tourist map."
    got := game.ListItems(input)
    if want != got {
        t.Error(cmp.Diff(want, got))
    }
}

(Listing game/2)

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 {
    result := "You can see here "
    result += strings.Join(items[:len(items)-1], ", ")
    result += ", and "
    result += items[len(items)-1]
    result += "."
    return result
}

(Listing game/3)

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 {
        input []string
        want  string
    }
    ...

(Listing game/4)

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.

...
cases := []testCase{
    {
        input: []string{
            "a battery",
            "a key",
            "a tourist map",
        },
        want:
        "You can see here a battery, a key, and a tourist map.",
    },
}
...

(Listing game/4)

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 {
    got := game.ListItems(tc.input)
    if tc.want != got {
        t.Error(cmp.Diff(tc.want, got))
    }
}

(Listing game/4)

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 {
        input []string
        want  string
    }
    cases := []testCase{
        {
            input: []string{
                "a battery",
                "a key",
                "a tourist map",
            },
            want:
            "You can see here a battery, a key, and a tourist map.",
        },
    }
    for _, tc := range cases {
        got := game.ListItems(tc.input)
        if tc.want != got {
            t.Error(cmp.Diff(tc.want, got))
        }
    }
}

(Listing game/4)

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:

{
    input: []string{
        "a battery",
        "a key",
    },
    want: "You can see here a battery and a key.",
},

(Listing game/5)

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!

Seven Rust books that don't suck

Seven Rust books that don't suck

Rust error handling is perfect actually

Rust error handling is perfect actually

0