Most of the time, it takes more than a single test case to validate that a function is working correctly. You could write multiple test functions to validate your function or multiple tests within the same function, but you’ll find that a great deal of the testing logic is repetitive. You set up supporting data and functions, specify inputs, check the outputs, and compare to see if they match your expectations.
Rather than writing this over and over, you can take advantage of a pattern called table tests. Let’s take a look at a sample. You can find this code in the examples/table directory. Assume you have the following function in the table package:
func DoMath(num1, num2 int, op string) (int, error) {
switch op {
case "+":
return num1 + num2, nil
case "-":
return num1 - num2, nil
case "*":
return num1 + num2, nil
case "/":
if num2 == 0 {
return 0, ErrDivZero
}
return num1 / num2, nil
default:
return 0, &UnknownOpErr{Op: op}
}
}
To test this function, you need to check the different branches, trying out inputs that return valid results, as well as inputs that trigger errors. You could write code like this, but it would be very repetitive:
func TestDoMath(t *testing.T) {
result, err := DoMath(2, 2, "+")
if result != 4 {
t.Error("Should have been 4, got", result)
}
if err != nil {
t.Error("Should have been nil error, got", err)
}
result2, err2 := DoMath(2, 2, "-")
if result2 != 0 {
t.Error("Should have been 0, got", result2)
}
if err2 != nil {
t.Error("Should have been nil error, got", err2)
}
// and so on...
}
Let’s replace this repetition with a table test. First, declare a slice of anonymous structs. The struct contains fields for the name of the test, the input parameters, and the return values. Each entry in the slice represents another test:
data := []struct {
name string
num1 int
num2 int
op string
expected int
err error
}{
{"addition", 2, 2, "+", 4, nil},
{"subtraction", 2, 2, "-", 0, nil},
{"multiplication", 2, 2, "*", 4, nil},
{"division", 2, 2, "/", 1, nil},
{"bad_division", 2, 0, "/", 0, ErrDivZero},
}
Next, loop over each test case in data, invoking the Run method each time. This is the line that does the magic. You pass two parameters to Run, a name for the subtest and a function with a single parameter of type *testing.T. Inside the function, you call DoMath by using the fields of the current entry in data, using the same logic over and over. When you run these tests, you’ll see that not only do they pass, but when you use the -v flag, each subtest also now has a name:
for _, d := range data {
t.Run(d.name, func(t *testing.T) {
result, err := DoMath(d.num1, d.num2, d.op)
if result != d.expected {
t.Errorf("Expected %d, got %d", d.expected, result)
}
if !errors.Is(err, d.err) {
t.Errorf("expected %v, got %v", d.err, err)
}
})
}
Tip
Comparing error messages in tests is fragile, because there may not be any compatibility guarantees on the message text. If you are testing code that uses
errors.Newandfmt.Errorfto make errors, you have three options: limit your testing to whether or not the returned error is nil, rewrite your code to use custom error types and sentinel errors, or compare the messages and hope for the best. It’s best to re-write your code.