Go Fundamentals - Sample
Table of Contents
Chapter 7.5: Test Helpers
Just like when we're writing "real" code, our tests sometimes need helper functions. These helper functions are called "test helpers". Test helpers might setup, or teardown, or provision resources for the test. They can be use to write assertions or to mock out external dependencies.
Defining Test Helpers
Test helpers functions in Go are just like any other function. The difference is that they are only defined in your tests. While not required, it is recommended that you take the testing.TB
interface, Listing 7.1, as the first argument to your test helper function. This interface is the common set of functions to both the testing.T
and testing.B
, used for benchmarking, types.
$ go doc testing.TB
package testing // import "testing"
type TB interface {
Cleanup(func())
Error(args ...any)
Errorf(format string, args ...any)
Fail()
FailNow()
Failed() bool
Fatal(args ...any)
Fatalf(format string, args ...any)
Helper()
Log(args ...any)
Logf(format string, args ...any)
Name() string
Setenv(key, value string)
Skip(args ...any)
SkipNow()
Skipf(format string, args ...any)
Skipped() bool
TempDir() string
// Has unexported methods.
}
TB is the interface common to T, B, and F.
--------------------------------------------------------------------------------
Go Version: go1.23.0
testing.TB
interface.Let's define some test helpers to help clean up tests in Listing 7.2. We will create helpers to create the different Store
implementations we need for the test.
func Test_Store_All_Errors(t *testing.T) {
t.Parallel()
tn := "users"
noData := &Store{}
withData := &Store{data: data{}}
withUsers := &Store{data: data{"users": Models{}}}
// create a slice of anonymous structs
// with the fields for each case.
tcs := []struct {
name string
store *Store
exp error
}{
// tests cases
{name: "no data", store: noData, exp: ErrNoData(tn)},
{name: "with data, no users", store: withData, exp: ErrTableNotFound{}},
{name: "with users", store: withUsers, exp: nil},
}
// loop through the tcs and test each case
for _, tc := range tcs {
t.Run(tc.name, func(t *testing.T) {
_, err := tc.store.All(tn)
ok := errors.Is(err, tc.exp)
if !ok {
t.Fatalf("expected error %v, got %v", tc.exp, err)
}
})
}
}
First, in Listing 7.3, we create two helper functions. The first, noData(testing.TB)
returns a &Store
that does not have any data. The second, withData(testing.TB)
returns a &Store
that has its data
field properly initialized.
func noData(_ testing.TB) *Store {
return &Store{}
}
func withData(_ testing.TB) *Store {
return &Store{
data: data{},
}
}
Store
values.In Listing 7.4 we declare the withUsers
helper function needed to clean up the tests. At the moment, however, we are unable to implement the withUsers
test helper so we can call the testing.TB.Fatal
method on the passed in testing.TB
to let Go that we haven't implemented this helper yet.
func withUsers(t testing.TB) *Store {
// go test reports this line as failing:
t.Fatal("not implemented")
return nil
}
Finally, in Listing 7.5, we can update our tests to use the new test helpers, passing in the testing.T
argument from the test to the helper function.
func Test_Store_All_Errors(t *testing.T) {
t.Parallel()
tn := "users"
tcs := []struct {
name string
store *Store
exp error
}{
{name: "no data", store: noData(t), exp: ErrNoData(tn)},
{name: "with data, no users", store: withData(t), exp: ErrTableNotFound{}},
{name: "with users", store: withUsers(t), exp: nil},
}
for _, tc := range tcs {
t.Run(tc.name, func(t *testing.T) {
_, err := tc.store.All(tn)
ok := errors.Is(err, tc.exp)
if !ok {
t.Fatalf("expected error %v, got %v", tc.exp, err)
}
})
}
}
As seen in Listing 7.6, because we have yet to implement the withUsers
helper our tests will fail.
$ go test -v
=== RUN Test_Store_All_Errors
=== PAUSE Test_Store_All_Errors
=== CONT Test_Store_All_Errors
store_test.go:24: not implemented
--- FAIL: Test_Store_All_Errors (0.00s)
FAIL
exit status 1
FAIL demo 3.046s
--------------------------------------------------------------------------------
Go Version: go1.23.0
When the test fails it reported the test failure as inside the withUsers
helper, Listing 7.4, and not within the test itself. This can make it difficult to debug the test failure.
Marking a Function as a Helper
In order to get Go to report the correct line number, inside of the test not the helper, we need to tell Go that the withData
function is a test helper. To do that we must use the testing.TB.Helper
, Listing 7.7, method inside of our test helpers.
$ go doc testing.T.Helper
package testing // import "testing"
func (c *T) Helper()
Helper marks the calling function as a test helper function. When printing
file and line information, that function will be skipped. Helper may be
called simultaneously from multiple goroutines.
--------------------------------------------------------------------------------
Go Version: go1.23.0
func withUsers(t testing.TB) *Store {
t.Helper()
t.Fatal("not implemented")
return nil
}
testing.TB.Helper
method.Now, when the test fails, in Listing 7.8, the line number reported will be the line number inside the test that failed, not the line number inside the helper.
$ go test -v
=== RUN Test_Store_All_Errors
=== PAUSE Test_Store_All_Errors
=== CONT Test_Store_All_Errors
store_test.go:44: not implemented
--- FAIL: Test_Store_All_Errors (0.00s)
FAIL
exit status 1
FAIL demo 2.487s
--------------------------------------------------------------------------------
Go Version: go1.23.0
table := []struct {
name string
store *Store
exp error
}{
{name: "no data", store: noData(t), exp: ErrNoData(tn)},
// go test now reports this line as the failure:
{name: "with data, no users", store: withData(t), exp: ErrTableNotFound{}},
{name: "with users", store: withUsers(t), exp: nil},
}
Cleaning up a Helper
It is very common that a test helper, or even a test itself, needs to clean up some resources when done. In order to do that we need to use the testing.TB.Cleanup
method, Listing 7.9. With testing.TB.Cleanup
we can pass in a function that will be called, automatically, when the test is done.
$ go doc testing.T.Cleanup
package testing // import "testing"
func (c *T) Cleanup(f func())
Cleanup registers a function to be called when the test (or subtest) and
all its subtests complete. Cleanup functions will be called in last added,
first called order.
--------------------------------------------------------------------------------
Go Version: go1.23.0
testing.TB.Cleanup
method.In Listing 7.10, we implement the withUsers
helper function. In it, we use the testing.TB.Cleanup
method to clean up the users we created.
func withUsers(t testing.TB) *Store {
t.Helper()
users := Models{
{"id": 1, "name": "John"},
{"id": 2, "name": "Jane"},
}
t.Cleanup(func() {
t.Log("cleaning up users", users)
})
return &Store{
data: data{
"users": users,
},
}
}
$ go test -v
=== RUN Test_Store_All_Errors
=== PAUSE Test_Store_All_Errors
=== CONT Test_Store_All_Errors
=== RUN Test_Store_All_Errors/no_data
=== RUN Test_Store_All_Errors/with_data,_no_users
=== RUN Test_Store_All_Errors/with_users
=== NAME Test_Store_All_Errors
store_test.go:30: cleaning up users [map[id:1 name:John] map[id:2 name:Jane]]
--- PASS: Test_Store_All_Errors (0.00s)
--- PASS: Test_Store_All_Errors/no_data (0.00s)
--- PASS: Test_Store_All_Errors/with_data,_no_users (0.00s)
--- PASS: Test_Store_All_Errors/with_users (0.00s)
PASS
ok demo 2.299s
--------------------------------------------------------------------------------
Go Version: go1.23.0
testing.T.Cleanup
to clean up helper resources.Clarifying "Cleanup" vs. "defer"
While the testing.TB.Cleanup
method might seem like a fancy defer
statement, it is actually a different concept. When a function is deferred that function will be called when its parent function returns. In the case of testing.TB.Cleanup
, the testing.TB.Cleanup
function will be called when the test is done.