Menu Close

Table-driven Tests in Go

Writing table-driven tests is a common pattern for unit tests in Go. They are frequently used in the Go standard library and are considered best practice for most testing scenarios. In this article, you’re going to learn about the advantages of table-driven tests and how to implement them.

What is a table-driven Test?

Let’s start with a simple Go function we want to test:

add.go

package add

func add(a, b int) int {
	return a + b
}

The function “add” returns the sum of two given integers. With a traditional testing approach, used in many other programming languages like Java, we would define a test function for every test case we want to test. This would look like this:

app_test.go

package add

import "testing"

func TestBothZero(t *testing.T) {
	expected := 0
	actual := add(0, 0)
	if actual != expected {
		t.Fatalf("expected: %d got: %d", expected, actual)
	}
}

func TestBothPositive(t *testing.T) {
	expected := 7
	actual := add(2, 5)

	if actual != expected {
		t.Fatalf("expected: %d got: %d", expected, actual)
	}
}
func TestBothNegative(t *testing.T) {
	expected := -7
	actual := add(-2, -5)

	if actual != expected {
		t.Fatalf("expected: %d got: %d", expected, actual)
	}
}

// many more test cases
// ...

With a table-driven test approach, we would define all test cases in a struct slice, which represents the test table. We then reduce the test implementation to one test function that loops over the test cases in the struct slice and makes appropriate assertions:

app_test.go

package add

import "testing"

func TestAdd(t *testing.T) {
	cases := []struct {
		desc     string
		a, b     int
		expected int
	}{
		{"TestBothZero", 0, 0, 0},
		{"TestBothPositive", 2, 5, 7},
		{"TestBothNegative", -2, -5, -7},
	}

	for _, tc := range cases {
		actual := add(tc.a, tc.b)
		if actual != tc.expected {
			t.Fatalf("%s: expected: %d got: %d for a: %d and b %d",
				tc.desc, actual, tc.expected, tc.a, tc.b)
		}
	}

}

A test case in this table-driven test is a struct with the string field “desc”, which holds the test description, and three int fields: “a” and “b” for the function input, and “expected” for the expected output. We print the “desc” field of each test case at the beginning of the error message for a failing test. So if a test fails, we get an understandable error message.

# changed add to return a + b + 1 to make this test failing
--- FAIL: TestAdd (0.00s)
    add_test.go:19: TestBothZero: expected: 0 got: 1 for a: 0 and b 0
FAIL
exit status 1
FAIL    table-driven-tests      0.002s

We now restructured our test functions to a table-driven test, but what is the advantage of this approach?

Why table-driven Tests?

One advantage of table-driven tests is that you get a very concise definition of multiple test cases. Just compare the example above. The one function per test case approach has significantly more lines of code than the refactored version. This effect gets even more noticeable if you have many more test cases.

Additionally, with table-driven tests, it is very easy to add new cases. You just have to add a new line to your table definition. This makes it easy for developers to adjust the tests to cases they didn’t think about when implementing the tests in the first place. This helps to be consistent with testing processes and the concept of adding a failing unit test case for bugs and edge cases before you fix them. To emphasize this let’s have a look on how easy it is to extend the test cases for our example:

app_test.go

package add

import "testing"

func TestAdd(t *testing.T) {
	cases := []struct {
		desc     string
		a, b     int
		expected int
	}{
		{"TestBothZero", 0, 0, 0},
		{"TestBothPositive", 2, 5, 7},
		{"TestBothNegative", -2, -5, -7},
		{"TestAZero", 0, 3, 3},
		{"TestBZero", 3, 0, 3},
		{"TestANegativeBPositive", -1, 5, 4},
	}

	for _, tc := range cases {
		actual := add(tc.a, tc.b)
		if actual != tc.expected {
			t.Fatalf("%s: expected: %d got: %d for a: %d and b %d",
				tc.desc, tc.expected, actual, tc.a, tc.b)
		}
	}

}

Run table-driven Tests as Subtests

In the implementation above we’re using t.Fatalf to signal a failing test. This exits the test after one test case failed. Sometimes you want to know the result for each test case. Since Go 1.7 you can do that with subtests. You can use the method “t.Run” with a name argument and a closure, that takes the testing.T pointer as an argument.

app_test.go

package add

import "testing"

func TestAdd(t *testing.T) {
	cases := []struct {
		desc     string
		a, b     int
		expected int
	}{
		{"TestBothZero", 0, 0, 0},
		{"TestBothPositive", 2, 5, 7},
		{"TestBothNegative", -2, -5, -7},
		{"TestAZero", 0, 3, 3},
		{"TestBZero", 3, 0, 3},
		{"TestANegativeBPositive", -1, 5, 4},
	}

	for _, tc := range cases {
		t.Run(tc.desc, func(t *testing.T) {
			actual := add(tc.a, tc.b)
			if actual != tc.expected {
				t.Fatalf("expected: %d got: %d for a: %d and b %d",
					tc.expected, actual, tc.a, tc.b)
			}
		})
	}

}

Notice we don’t longer put the test case description in the error message but use it as the name argument for t.Run

Running failing tests should now look like this:

# changed add to return a + b + 1 to make this tests failing
--- FAIL: TestAdd (0.00s)
    --- FAIL: TestAdd/TestBothZero (0.00s)
        add_test.go:23: expected: 0 got: 1 for a: 0 and b 0
    --- FAIL: TestAdd/TestBothPositive (0.00s)
        add_test.go:23: expected: 7 got: 8 for a: 2 and b 5
    --- FAIL: TestAdd/TestBothNegative (0.00s)
        add_test.go:23: expected: -7 got: -6 for a: -2 and b -5
    --- FAIL: TestAdd/TestAZero (0.00s)
        add_test.go:23: expected: 3 got: 4 for a: 0 and b 3
    --- FAIL: TestAdd/TestBZero (0.00s)
        add_test.go:23: expected: 3 got: 4 for a: 3 and b 0
    --- FAIL: TestAdd/TestANegativeBPositive (0.00s)
        add_test.go:23: expected: 4 got: 5 for a: -1 and b 5
FAIL
exit status 1
FAIL    table-driven-tests      0.004s

Conclusion

Table-driven tests are a great way to unit test your Go code. They keep your tests concise and enable you to easily add new test cases to your tests. You should use table-driven tests if you have many test cases for a single function. Some Go developers argue that you should even use them if you only have 1 or 2 test cases. It could always be that you have to add another test case, and if you have a table-driven test in place it will save you time.