Expectations in Go Unit Tests
Or: how to make table tests clearer and more developer-friendly.
I read this article a few weeks ago. It is filled with some really useful tips with regards to unit testing in Go, and how to write better tests.
One such example that I'm sure you're familiar with is table tests:
func TestReverse(t *testing.T) {
type testcase struct {
input string
expected string
}
tests := map[string]testcase{
"reverses the input": {
input: "test",
expected: "tset",
},
}
for name, test := range tests {
t.Run(name, func(t *testing.T) {
actual := reverse(test.input)
if actual != test.expected {
t.Fatalf("got %s, expected %s", actual, test.expected)
}
})
}
}
Hopefully pretty straightforward.
However, when return values from functions are more than a single item, it can be complex to set up a table test in this manner.
Imagine the reverse function could return an error. We'd then need to add an expected error to the test case, and then add more tests as part of the test body:
func TestReverse(t *testing.T) {
type testcase struct {
input string
expected string
expectedErr error
}
tests := map[string]testcase{
"returns error on empty string": {
input: "",
expected: "",
expectedErr: ErrEmptyString,
},
}
for name, test := range tests {
t.Run(name, func(t *testing.T) {
actual, err := reverse(test.input)
if test.expectedErr != nil {
if !errors.Is(err, test.expectedErr) {
t.Fatalf("got %+v, expected %+v", err, test.expectedErr)
}
}
if actual != test.expected {
t.Fatalf("got %s, expected %s", actual, test.expected)
}
})
}
}
This adds a layer of indirection and fragility to your table tests: the assertions don't apply to all the tests in the table - they're guarded by checking the values of the test before running the assertion.
In this way, adding a new test case, or adding an additional assertion can leak out and cause other tests in the same test table to start failing.
Anyway, towards the end of this article, it suggests that, instead of adding expected
and expectedErr
to the table test, instead you should add a slice of expectation
s:
type expectation func(t *testing.T, actual string, err error)
type testcase struct {
input string
expectations []expectation
}
This is a slice of functions that accept the test instance, a string and an error—the last two parameters being the response from the reverse
call.
The idea being that, rather than modifying the test body as you need to account for more test cases, you add more items to this slice of expectations, and simply loop through them inside the test body:
actual, err := reverse(test.input)
for _, exp := range test.expectations {
exp(t, actual, err)
}
These expectation functions would look something like the following:
tests := map[string]testcase{
"returns error on empty string": {
input: "",
expectations: []expectation{
func(t *testing.T, actual string, err error) {
if !errors.Is(err, ErrEmptyString) {
t.Fatalf("got %+v, expected %+v", err, ErrEmptyString)
}
},
// etc ...
},
},
}
What's more, these check functions can be refactored out into standalone higher-order functions, and then used much more succinctly in the test case:
func ErrIs(expected error) expectation {
return func(t *testing.T, actual string, err error) {
if !errors.Is(err, expected) {
t.Fatalf("got %+v, expected %+v", err, expected)
}
}
}
// ...
tests := map[string]testcase{
"returns error on empty string": {
input: ""
expectations: []expectation{
ErrIs(ErrEmptyString)
},
}
}
It'd also be entirely possible to create a suite of helper functions, now that generics are available in Go, rather than needing to create a separate expectation
type for each response shape.
Putting it all together, in my opinion is it a much cleaner, clearer and developer-friendly way to write tests:
type expectation func(t *testing.T, actual string, err error)
func ErrIs(expected error) expectation {
return func(t *testing.T, actual string, err error) {
if !errors.Is(err, expected) {
t.Fatalf("got %+v, expected %+v", err, expected)
}
}
}
func TestReverse(t *testing.T) {
type testcase struct {
input string
expectations []expectation
}
tests := map[string]testcase{
"returns error on empty string": {
input: "",
expectations: []expectation{
ErrIs(ErrEmptyString),
},
},
}
for name, test := range tests {
t.Run(name, func(t *testing.T) {
actual, err := reverse(test.input)
for _, exp := range test.expectations {
exp(t, actual, err)
}
})
}
}
Comments ()