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 or splitContains closures in a way that doesn’t require another for loop
  • Traditional Go tests name the thing they are testing TestSplit and the test input simple, but give no name to the logic used for testing. Closure driven tests add the name splitEquals to your code.
  • We avoid the unnatural compact one linestruct{} 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.