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.19

Listing 7.1: The 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)
			}
		})
	}
}
Listing 7.2: The test function to refactor with helpers.

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(t testing.TB) *Store {
	return &Store{}
}

func withData(t testing.TB) *Store {
	return &Store{
		data: data{},
	}
}
Listing 7.3: Two helper functions for initializing 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
}
Listing 7.4: An unimplemented helper function.

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)
			}
		})
	}
}
Listing 7.5: The test function with the new helpers.

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	0.328s

--------------------------------------------------------------------------------
Go Version: go1.19

Listing 7.6: The stack trace prints the line in the helper that failed.

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.19
func withUsers(t testing.TB) *Store {
	t.Helper()
	t.Fatal("not implemented")
	return nil
}
Listing 7.7: The 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	0.368s

--------------------------------------------------------------------------------
Go Version: go1.19
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},
}
Listing 7.8: The stack track now points to the test line that failed.

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.19

Listing 7.9: The 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
=== CONT  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	0.499s

--------------------------------------------------------------------------------
Go Version: go1.19
Listing 7.10: Using 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.


Subscribe to our newsletter