Writing is basically an iterative process. It is a rare writer who dashes out a finished piece; most of us work in circles. —Dale Dougherty & Tim O’Reilly, “Unix Text Processing”
In Programming with confidence, we started building an interactive fiction game with Go, with an unusual twist: before we wrote any part of the game code, we started by writing a test for that code.
Weird, right? How can you test something before you write it? Well, maybe it’s not that strange. After all, it’s the same way we deal with new drivers: we make sure they can pass a driving test before we send them out on the road.
The game is the game
In this final part of our series on test-driven developme…
Writing is basically an iterative process. It is a rare writer who dashes out a finished piece; most of us work in circles. —Dale Dougherty & Tim O’Reilly, “Unix Text Processing”
In Programming with confidence, we started building an interactive fiction game with Go, with an unusual twist: before we wrote any part of the game code, we started by writing a test for that code.
Weird, right? How can you test something before you write it? Well, maybe it’s not that strange. After all, it’s the same way we deal with new drivers: we make sure they can pass a driving test before we send them out on the road.
The game is the game
In this final part of our series on test-driven development (TDD) in Go, let’s play a game called “Red, Green, Refactor”. We’ll start by fixing up our test for the ListItems function to include the remaining input cases, and seeing it fail: that’s “Red”. Next, we’ll make it pass: that’s “Green”. Finally, we’ll clean up, simplify, and polish the working code: that’s “Refactor”.
Last time, in Shameless green, we ended up with a failing test for the case where we want the game to show us two items, like this:
You can see here a battery and a key.
The problem is that the ListItems function currently always separates the listed items with a comma. For three or more items, we will want the comma, and for two, one, or zero items, we won’t. So the quickest way to get this test to pass is probably to add a special case to ListItems, when len(items) is less than 3:
func ListItems(items []string) string {
result := "You can see here "
if len(items) < 3 {
return result + items[0] + " and " + items[1] + "."
}
result += strings.Join(items[:len(items)-1], ", ")
result += ", and "
result += items[len(items)-1]
result += "."
return result
}
Again, this isn’t particularly elegant, nor does it need to be. We just need to write the minimum code to pass the current failing test case. In particular, we don’t need to worry about trying to pass test cases we don’t have yet, even if we plan to add them later:
Add one case at a time, and make it pass before adding the next.
The test passes for the two cases we’ve defined, so now let’s add the one-item case:
{
input: []string{
"a battery",
},
want: "You can see a battery here.",
},
Note the slightly different word order: “you can see here a battery” would sound a little odd.
Let’s see if this passes:
--- FAIL: TestListItems_GivesCorrectResultForInput (0.00s)
panic: runtime error: index out of range [1] with length 1
[recovered]
Oh dear. ListItems is now panicking, so that’s even worse than simply failing. In the immortal words of Wolfgang Pauli, it’s “not even wrong”.
Quelling a panic
Panics in Go are accompanied by a stack trace, so we can work our way through it to see which line of code is the problem. It’s this one:
return result + items[0] + " and " + items[1] + "."
This is being executed in the case where there’s only one item (items[0]), so we definitely can’t refer to items[1]: it doesn’t exist. Hence the panic.
Let’s treat the one-item list as another special case:
func ListItems(items []string) string {
result := "You can see here "
if len(items) == 1 {
return "You can see " + items[0] + " here."
}
if len(items) < 3 {
return result + items[0] + " and " + items[1] + "."
}
result += strings.Join(items[:len(items)-1], ", ")
result += ", and "
result += items[len(items)-1]
result += "."
return result
}
This eliminates the panic, and the test now passes for this case.
Let’s keep going, and add the zero items case. What should we expect ListItems to return?
{
input: []string{},
want: "",
},
Just the empty string seems reasonable. We could have it respond “You see nothing here”, but it would be a bit weird to get that message every time you enter a location that happens to have no items in it, which would probably be true for most locations.
Running this test case panics again:
--- FAIL: TestListItems_GivesCorrectResultForInput (0.00s)
panic: runtime error: index out of range [0] with
length 0 [recovered]
And we can guess what the problem is without following the stack trace: if items is empty, then we can’t even refer to items[0]. Another special case:
if len(items) == 0 {
return ""
}
This passes.
Refactoring
We saw earlier that it doesn’t matter how elegant the code for ListItems looks, if it doesn’t do the right thing. So now that it does do the right thing, we’re in a good place, because we have options. If we had to ship right now, this moment, we could actually do that. We wouldn’t be delighted about it, because the code is hard to read and maintain, but users don’t care about that. What they care about is whether it solves their problem.
But maybe we don’t have to ship right now. Whatever extra time we have in hand, we can now use to refactor this correct code to make it nicer. And, while there’s always a risk of making mistakes or introducing bugs when refactoring, we have a safety net: the test.
The definition of “refactoring”, by the way, is changing code without changing its behaviour in any relevant way. Since the test defines all the behaviour we consider relevant, we can change the code with complete freedom, relying on the test to tell us the moment the code starts behaving differently.
Since we have four different code paths, depending on the number of input items, we can more elegantly write that as a switch statement with four cases:
func ListItems(items []string) string {
switch len(items) {
case 0:
return ""
case 1:
return "You can see " + items[0] + " here."
case 2:
return "You can see here " + items[0] + " and " +
items[1] + "."
default:
return "You can see here " +
strings.Join(items[:len(items)-1], ", ") +
", and " + items[len(items)-1] + "."
}
}
Did we break anything or change any behaviour? No, because the test still passes. Could we have written ListItems from the start using a switch statement, saving this refactoring step? Of course, but we’ve ended up here anyway, just by a different route.
In fact, all good programs go through at least a few cycles of refactoring. We shouldn’t even try to write the final program in a single attempt. Instead, we’ll get much better results by aiming for correct code first, then iterating on it a few times to make it clear, readable, and easy to maintain.
Sounds good, now what?
Maybe you’re intrigued, or inspired, by the idea of programming with confidence, guided by tests. But maybe you still have a few questions. For example:
How does Go’s testing package work? How do we write tests to communicate intent? How can we test error handling? How do we come up with useful test data? What about the test cases we didn’t think of?
What if there are bugs still lurking, even when the tests are passing? How can we test things that seem untestable, like concurrency, or user interaction? What about command-line tools?
How can we deal with code that has too many dependencies, and modules that are too tightly coupled? Can we test code that talks to external services like databases and network APIs? What about testing code that relies on time? And are mock objects a good idea? How about assertions?
What should we do when the codebase has no tests at all? For example, legacy systems? Can we refactor and test them in safe, stress-free ways? What if we get in trouble with the boss for writing tests? What if the existing tests are no good? How can we improve testing through code review?
How should we deal with tests that are optimistic, persnickety, over-precise, redundant, flaky, failing, or just slow? And how can tests help us improve the design of the system overall?
Well, I’m glad you asked. The Power of Go: Tests is just the book for you, and you’ll find all the answers there. If you enjoyed this sneak preview, I hope you’ll do me a favour and grab yourself the full book. Thanks for your support, and keep programming with confidence!