Testing in Go: Table-Driven Tests
Table of Contents
Coming from Ruby, which has excellent testing tools and libraries, the notion of table-driven tests was unusual for me. The widespread testing libraries in Ruby, such as RSpec, force the programmer to approach testing from a BDD standpoint. Thus, coming to Go and learning about the table-driven test was a new way of looking at tests for me.
Looking back, Dave Cheney’s 2013 seminal blog post “Writing table driven-tests
in Go”
was very likely my gateway to table-driven tests. In it, he points out to the
tests of the math
[source] and
time
[source] packages, where
The Go authors have used table-driven tests. I encourage you to go visit these
two links, they offer a good perspective to testing in Go.
I remember that at the beginning, the idea of table-driven tests was
exceptionally provocative. The Rubyist in me was screaming, “What is this
blasphemy?!”. “These weird for
loops don’t seem right” and “What are these
data structures that I have to define to run a simple spec!?” These were some
of the first thoughts that came to my mind.
The approach is very far from bad. Go’s philosophy to testing is different from Ruby’s, yet it has an identical goal: make sure that our code works as expected so that we can sleep tight at night.
Let’s explore table-driven tests, understand their background, the approach, and their pros and cons.
What are table-driven tests? #
As the name suggests, these are tests that are driven by tables. So, you might be wondering, “what kind of tables?!”. Here’s the idea: every function under test has inputs and expected outputs.
For example, the function Max
docs from
the math
package takes two arguments and has one return value. Both arguments
are numbers of type float64
, and the returned value is also a float64
number. So, when invoked, Max
will return the larger number from the two
arguments. So, Max
has two inputs and one expected output. The output is one
of the inputs.
What would a test look like for Max
? First, we would probably test its basic
functionality, e.g., between 1
and 2
, it will return 2
. Also, we will
probably test with negative numbers, e.g., between -100
and -200
it will
return-100
. Then, we will probably throw in a test that uses 0
or some
arbitrary floating-point number. Finally, we can try the edge cases - huge and
tiny numbers. Who knows, maybe we can hit some edge case.
Looking at the above paragraph, the input values and the expected outcomes change. Still, the number of values that are in play is always the same, three: two arguments and one expected return value. Given that the value number is constant, we can put it in a table:
Argument 1 | Argument 2 | Code representation | Expected return |
---|---|---|---|
1 | 2 | Max(1, 2) | 2 |
-100 | -200 | Max(-100, -200) | -100 |
0 | -200 | Max(0, -200) | 0 |
-100 | 0 | Max(-100, 0) | 0 |
100 | 0 | Max(100, 0) | 100 |
0 | 200 | Max(0, 200) | 200 |
100 | 0 | Max(100, 0) | 100 |
0 | 200 | Max(0, 200) | 200 |
-8.31373e-02 | 1.84273e-02 | Max(-8.31373e-02, 1.84273e-02) | 1.84273e-02 |
Following this idea, what if we would try to express this table in a very simple Go structure?
type TestCase struct {
arg1 float64
arg2 float64
expected float64
}
That should do the trick: it has three attributes of type float64
: arg1
,
arg2
and expected
. We are going to skip the third column as that is only
there for more clarity.
What about the data? Could we next add the data to a slice of TestCase
? Let’s
give it a shot:
func TestMax(t *testing.T) {
cases := []TestCase{
TestCase{
arg1: 1.0,
arg2: 2.0,
expected: 2.0,
},
TestCase{
arg1: -100,
arg2: -200,
expected: -100,
},
TestCase{
arg1: 0,
arg2: -200,
expected: 0,
},
TestCase{
arg1: -8.31373e-02,
arg2: 1.84273e-02,
expected: 1.84273e-02,
},
}
}
We intentionally omitted some of the cases for brevity and because what we have
above clearly painted the picture. We have a test function already and cases
of type []TestCase
. The last piece of the puzzle is to iterate over the
slice. For each of the TestCase
structs invoke the Max
function using the
two arguments. Then, compare the expected
attribute of the TestCase
with
the actual result of the invocation of Max
.
func TestMax(t *testing.T) {
cases := []TestCase{
TestCase{
arg1: 1.0,
arg2: 2.0,
expected: 2.0,
},
TestCase{
arg1: -100,
arg2: -200,
expected: -100,
},
TestCase{
arg1: 0,
arg2: -200,
expected: 0,
},
TestCase{
arg1: -8.31373e-02,
arg2: 1.84273e-02,
expected: 1.84273e-02,
},
}
for _, tc := range cases {
got := math.Max(tc.arg1, tc.arg2)
if got != tc.expected {
t.Errorf("Max(%f, %f): Expected %f, got %f", tc.arg1, tc.arg2, tc.expected, got)
}
}
}
Let’s dissect the for
loop:
For each of the cases
, we invoke the math.Max
function, with tc.arg1
andtc.arg2
as arguments. Then, we compare what the invocation returned with
the expected value in tc.expected
. The comparison will tells us if math.Max
returned what we expected, and if that’s not the case, it will mark the test as
failed. If any of the tests fail, the error message will look like this:
$ go test math_test.go -v
=== RUN TestMax
--- FAIL: TestMax (0.00s)
math_test.go:41: Max(-0.083137, 0.018427): Expected 0.000000, got 0.018427
FAIL
FAIL command-line-arguments 0.004s
Having a structured and typed test case is the magic behind table-driven tests.
It’s also the reason for the name: a TestCase
represents a row from a table.
With the for
loop we evaluate each of the rows and use its cells as arguments
and expected values.
Convert ordinary to table-driven tests #
As always, when talking about programming, it’s easier if we write some actual code while talking. In this section, we will first add some straightforward tests. After that, we will convert them to table-driven tests.
Consider this type Person
, which has two functions: older
and NewPerson
.
The latter being a constructor, while the former is a function that can decide
what Person
is older between two of them:
package person
import "errors"
var (
AgeTooLowError = errors.New("A person must be at least 1 years old")
AgeTooHighError = errors.New("A person cannot be older than 130 years")
)
type Person struct {
age int
}
func NewPerson(age int) (error, *Person) {
if age < 1 {
return AgeTooLowError, nil
}
if age >= 130 {
return AgeTooHighError, nil
}
return nil, &Person{
age: age,
}
}
func (p *Person) older(other *Person) bool {
return p.age > other.age
}
Next, let’s add some tests for these two functions:
package person
import (
"testing"
)
func TestNewPersonPositiveAge(t *testing.T) {
err, _ := NewPerson(1)
if err != nil {
t.Errorf("Expected person, received %v", err)
}
}
func TestNewPersonNegativeAge(t *testing.T) {
err, p := NewPerson(-1)
if err == nil {
t.Errorf("Expected error, received %v", p)
}
}
func TestNewPersonHugeAge(t *testing.T) {
err, p := NewPerson(150)
if err == nil {
t.Errorf("Expected error, received %v", p)
}
}
func TestOlderFirstOlderThanSecond(t *testing.T) {
_, p1 := NewPerson(1)
_, p2 := NewPerson(2)
if p1.older(p2) {
t.Errorf("Expected p1 with age %d to be younger than p2 with age %d", p1.age, p2.age)
}
}
func TestOlderSecondOlderThanFirst(t *testing.T) {
_, p1 := NewPerson(2)
_, p2 := NewPerson(1)
if !p1.older(p2) {
t.Errorf("Expected p1 with age %d to be older than p2 with age %d", p1.age, p2.age)
}
}
These tests are conventional. Also, the tests covering the same function
typically have the same structure of setup, assertion, and error reporting.
Having a similar test layout is another reason why table-driven tests are good.
Table-driven tests eliminate the repetition of boilerplate code and substitute
it with a simple for
loop.
Let’s refactor the tests into table-driven tests. We will begin with a
TestOlder
function:
func TestOlder(t *testing.T) {
cases := []struct {
age1 int
age2 int
expected bool
}{
{
age1: 1,
age2: 2,
expected: false,
},
{
age1: 2,
age2: 1,
expected: true,
},
}
for _, c := range cases {
_, p1 := NewPerson(c.age1)
_, p2 := NewPerson(c.age2)
got := p1.older(p2)
if got != c.expected {
t.Errorf("Expected %v > %v, got %v", p1.age, p2.age, got)
}
}
}
There isn’t much happening here. The only difference compared to the tests we
saw before is the inline definition and initialization of the cases
slice.
We define the type with its attributes and add values to it right away instead
of first defining the type and initializing a slice of it after.
Next, we will create a TestNewPerson
function:
func TestNewPerson(t *testing.T) {
cases := []struct {
age int
err error
}{
{
age: 1,
err: nil,
},
{
age: -1,
err: AgeTooLowError,
},
{
age: 150,
err: AgeTooHighError,
},
}
for _, c := range cases {
err, _ := NewPerson(c.age)
if err != c.err {
t.Errorf("Expected %v, got %v", c.err, err)
}
}
}
This test follows the same structure: defining the cases
slice by
initializing the slice inline. Then, in the loop, we assert that the errors
that we expect are the same as the ones returned by the invocation of the
NewPerson
function.
If you have a test file that you would like to refactor to use a table-driven approach, follow these steps:
- Group all tests that focus on one function one after another in the test file
- Identify the inputs/arguments to the function under test in each of the test functions
- Identify the expected output on each of the tests
- Extract the inputs and the expected outputs into another test, wrapping them
into a type (
struct
) that will accommodate all inputs and the expected output - Create a slice of the new type, populate it with all inputs and expected outputs and introduce a loop where you will create the assertion between the expected and the actual output
Why should you use table-driven tests? #
One of the reasons I like the table-driven approach to testing is how
effortless it is to add different test cases. Table-driven tests make adding a
new test case to just adding another entry in the cases
slice. Compared to
the classic style of writing a test function where you have to figure out a
name for the function, set up the state, and execute the assertion,
table-driven tests make this a breeze.
Table-driven tests centralize the actual test of a function to a single function block. The classical approach to testing has only one set of inputs and expected outputs within one single function block. When using table-driven tests, we can add virtually unlimited test cases within a single test function block. In other words, table-driven tests are just a DRYed out version of the classical approach.
Lastly, having all cases centralized in a single slice gives more transparency to the quality of our test inputs. For example, are we trying to use arbitrary big or small numbers as inputs, or very long and very short strings, etc.? You get the idea.
Let’s take a quick look at the TestOlder
test function again:
func TestOlder(t *testing.T) {
cases := []struct {
age1 int
age2 int
expected bool
}{
{
age1: 1,
age2: 2,
expected: false,
},
{
age1: 2,
age2: 1,
expected: true,
},
}
for _, c := range cases {
_, p1 := NewPerson(c.age1)
_, p2 := NewPerson(c.age2)
got := p1.older(p2)
if got != c.expected {
t.Errorf("Expected %v > %v, got %v", p1.age, p2.age, got)
}
}
}
If I ask you: only by looking at the cases
slice, what kind of other test
cases can you come up with, what would you answer? One case that immediately
comes to mind is testing when the two age int
’s are the same. We can add more
cases, but I’ll let you think that one through. (Hint: think about edge cases.
😉)
It’s not all rainbows and unicorns. This approach has some downsides. For example, running a specific test case (via `go test -run foo’) is more difficult - we cannot target a single case; we have to run the whole function. But, there’s a trick to achieve both: it’s called subtests and we’ll look into them in another article.