Table driven tests are a popular model for testing in Go and a clear improvement to traditional testing of one function per case. However, there’s an alternative style that can often be clearer than table driven tests that relies on function closures.
I’ll reference the example code on Dave Cheney’s post Prefer table driven tests for my examples.
The original way Link to heading
The code we want to test is a function that splits strings.
// Split slices s into all substrings separated by sep and
// returns a slice of the substrings between those separators.
func Split(s, sep string) []string {
var result []string
i := strings.Index(s, sep)
for i > -1 {
result = append(result, s[:i])
s = s[i+len(sep):]
i = strings.Index(s, sep)
}
return append(result, s)
}
The original way to test code like this was to create different test functions for each case. Here are two tests: one for a wrong separator and another for no separator.
func TestSplitWrongSep(t *testing.T) {
got := Split("a/b/c", ",")
want := []string{"a/b/c"}
if !reflect.DeepEqual(want, got) {
t.Fatalf("expected: %v, got: %v", want, got)
}
}
func TestSplitNoSep(t *testing.T) {
got := Split("abc", "/")
want := []string{"abc"}
if !reflect.DeepEqual(want, got) {
t.Fatalf("expected: %v, got: %v", want, got)
}
}
Table driven test example Link to heading
As a table driven test, we create a struct object to hold the common parts of the test case. This has many advantages: the biggest of which is that we can add more test cases easily.
func TestSplit(t *testing.T) {
tests := map[string]struct {
input string
sep string
want []string
}{
"simple": {input: "a/b/c", sep: "/", want: []string{"a", "b", "c"}},
"wrong sep": {input: "a/b/c", sep: ",", want: []string{"a/b/c"}},
"no sep": {input: "abc", sep: "/", want: []string{"abc"}},
"trailing sep": {input: "a/b/c/", sep: "/", want: []string{"a", "b", "c"}},
}
for name, tc := range tests {
t.Run(name, func(t *testing.T) {
got := Split(tc.input, tc.sep)
diff := cmp.Diff(tc.want, got)
if diff != "" {
t.Fatalf(diff)
}
})
}
}
The common parts of traditional table driven tests are
- a structure to represent the test case
- a loop around an array or map of the structure
- testing logic inside the main body of the loop
Closure driven test example Link to heading
We can make a closure of our main testing body and rewrite the above code.
func TestSplit2(t *testing.T) {
splitEquals := func(input string, sep string, want []string) func(t *testing.T) {
return func(t *testing.T) {
got := Split(input, sep)
diff := cmp.Diff(want, got)
if diff != "" {
t.Fatalf(diff)
}
}
}
t.Run("simple", splitEquals("a/b/c", "/", []string{"a", "b", "c"}))
t.Run("wrong_sep", splitEquals("a/b/c", ",", []string{"a/b/c"}))
t.Run("nosep", splitEquals("abc", "/", []string{"abc"}))
t.Run("trailingsep", splitEquals("a/b/c/", "/", []string{"a", "b", "c"}))
}
The common parts of closure driven tests are:
- Defined test case parameters as function parameters rather than struct parameters
- A function that returns
func (t *testing.T)
, often inline with the test - Inline executions of cases rather than a loop
The most important part is that we can use the return value of splitEquals
directly as the second parameter to t.Run
.
Comparing closure driven tests to table driven tests Link to heading
A few advantages stick out for closure driven tests.
TestSplit2
has fewer lines of code, but is still easy to read- It’s obvious how to add other
splitNotEquals
orsplitContains
closures in a way that doesn’t require another for loop - Traditional Go tests name the thing they are testing
TestSplit
and the test inputsimple
, but give no name to the logic used for testing. Closure driven tests add the namesplitEquals
to your code. - We avoid the unnatural compact one line
struct{}
style of table driven tests
Which you use is more of a personal choice, but I’ve found closure driven tests easier to extend and develop iteratively. I am using them for most my current code.