Advanced Go table driven tests
Table Driven Tests (TDTs) provides a Don’t Repeat Yourself (DRY) approach of separating test variables from the actual reusable test logic. The separation of two concerns nourishes the clarity of what we are trying to achieve with our tests. A combination of subtests and testify provides all the tools you would ever need to write advanced tests in Go.
There are multiple great articles out there that talk about TDTs, to give kudos, I have to mention Dave Cheney’s “Writing table driven tests in Go” article which dates back to 2013, nearly seven years ago before this post, which probably is the inspiration of TDTs in Go for most of the developers that I know. I also want to thank fellow gophers who suggested me to give this testing approach a try, without you, testing in Go wouldn’t be as pleasant as it’s now.
subtests
In TDTs, we loop over test cases. The more advanced approach is to use subtests as this provides means of:
- running tests cases in parallel
- executing a specific test case in isolation from other cases
in parallel
Let’s say we have two tests TestFoo
and TestBar
, if we would want to execute both tests in parallel, we would use Parallel in each of these tests. However, it’s also possible to run each subtest test case in parallel by calling Parallel on each iteration, and this at first might be quite confusing.
What complicates things a bit further is that if we decided to run tests in parallel, we need to capture the range variable to ensure that we aren’t sharing the test case variable between iterations.
package main
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
)
func TestFoo(t *testing.T) {
t.Parallel() // marks TestFoo to be run in parallel with TestBar
for name, tc := range map[string]struct {
description string
myString string
expectToBeFoo bool
}{
"IsFoo": {
description: "My string is foo.",
myString: "foo",
expectToBeFoo: true,
},
"IsNotFoo": {
description: "My string isn't foo",
myString: "bar",
expectToBeFoo: false,
},
} {
tc := tc // capturing range variable
t.Run(name, func(t *testing.T) {
t.Parallel() // run each test case in parallel
t.Log(tc.description)
if tc.expectToBeFoo {
assert.Equal(t, tc.myString, foo())
} else {
assert.NotEqual(t, tc.myString, foo())
}
})
}
}
func TestBar(t *testing.T) {
t.Parallel() // marks TestBar to be run in parallel with TestFoo
for name, tc := range map[string]struct {
description string
myString string
expectToBeBar bool
}{
"IsFoo": {
description: "My string is bar.",
myString: "bar",
expectToBeBar: true,
},
"IsNotFoo": {
description: "My string isn't bar",
myString: "foo",
expectToBeBar: false,
},
} {
tc := tc // capturing range variable
t.Run(name, func(t *testing.T) {
t.Parallel() // run each test case in parallel
t.Log(tc.description)
if tc.expectToBeBar {
assert.Equal(t, tc.myString, bar())
} else {
assert.NotEqual(t, tc.myString, bar())
}
})
}
}
func foo() string {
time.Sleep(5 * time.Second)
return "foo"
}
func bar() string {
time.Sleep(5 * time.Second)
return "bar"
}
Practice makes perfect:
- Comment out
t.Parallel()
function calls and observe the time difference of test execution. With allt.Parallel()
function calls commented out, tests take 20 seconds to complete compared to only 5 seconds when all tests run in parallel. - To see why capturing the range variable matters, comment out the test case from the
TestFoo
test function and setexpectToBeFoo
value totrue
. Run the test and observe the test results.
testify assertion functions
Testify provides assertion functions which makes assertions dynamical per each test case, which in return increases the clarity of tests. Currently Testify provides 4 assertion functions used to:
ComparisonAssertionFunc
- compare two valuesValueAssertionFunc
- validate a single valueBoolAssertionFunc
- validate boolean valueErrorAssertionFunc
- validate error value
We can refactor the previous TestFoo
test function example to make use of ComparisonAssertionFunc
with assert.Equal
and assert.NotEqual
.
func TestFoo(t *testing.T) {
t.Parallel() // marks TestFoo to be run in parallel with TestBar
for name, tc := range map[string]struct {
description string
myString string
assertEqual assert.ComparisonAssertionFunc // provides means of dynamically asserting two values per each test case
}{
"IsFoo": {
description: "My string is foo.",
myString: "foo",
assertEqual: assert.Equal,
},
"IsNotFoo": {
description: "My string isn't foo",
myString: "bar",
assertEqual: assert.NotEqual,
},
} {
tc := tc // capturing range variable
t.Run(name, func(t *testing.T) {
t.Parallel() // run each test case in parallel
t.Log(tc.description)
tc.assertEqual(t, tc.myString, foo())
})
}
}
require no errors
When setting up tests, sometimes developers tend to assign errors to the blank identifier, as probably there is no point of checking errors, right? Well not really, we should always check for errors, even when we think no errors might occur, you never really know when this might come back and bite you. For this reason, I use testify require package to terminate my tests early if I got an unexpected error while setting up my tests.