Test Helper Function Guidelines for Go

When writing unit tests, we often use functions and methods to factor our test suites into coherent parts for readability. To common parts we factor out are creation methods and custom assertions. We use creation methods to group setup logic into a logical step for the test, and custom assertions to group complex result verification into a single logical expectation.

In Go, the standard testing library has a facility for marking such functions/methods as helpers. It is the (*testing.T).Helper() method, which tells the testing framework that the current function is a helper function and should not be included in error reports.

func Test_user_login(t *testing.T) {
  t.Run("sends correct login request", todo)
}

// todo is not a test helper function: it does not call t.Helper()
func todo(t *testing.T) {
  t.Skip("TODO!")
}

// assertExpectedRequestSent (a custom assertion) is a test helper function.
func assertExpectedLoginRequestSent(t *testing.T, request *http.Request, login Login) {
  t.Helper()
  // rest of function body
}

// setupTransferParticipantAccounts (a creation method) is a test helper function.
func (s suite) setupTransferParticipantAccounts(t *testing.T) (sender Account, recipient Account) {
  t.Helper()
  // setup code that prepares the SUT
}

In this blog post, I'll suggest some best practices for using t.Helper() to write better tests in Go.

What makes a test helper function

We have the following as the Go documentation for t.Helper (as at Go 1.21).

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

ℹ️ This documentation comment has three sentences. The first sentence states the general purpose of the function; the second sentence describes its side effects; and the third sentence states its safety guarantees (t.Helper is goroutine-safe — it has deterministic behavior under concurrent usage).
This is a good style for documentation comments.

The second sentence describes the side effects that we care about. It is this omission of file and line information that makes the function a helper function. In other words, a helper function (in this context) is one whose body is not relevant to the test output or failure. It can be treated as a single opaque statement at the call site, and any error report will be marked at the call site, not inside the function body.

Whenever we create a test helper, we have to decide whether we want to see the actual file and line information if this function causes the test to fail.

The guidelines

Assert preconditions and postconditions in test helpers

Test helpers will have a *testing.T argument and probably other arguments. At the start of the function, assert the preconditions and fail if they are not met. Before returning from the function, assert the post-conditions also. This practice documents the contract of the helper, detects errors early, and catches drifts. As you use this helper in more tests, you may violate some assumptions or find that it does not cover particular usage scenarios. These assertions should make you stop and think about what the helper is actually for.

func (s suite) setupTransferParticipantAccounts(t *testing.T) (sender Account, recipient Account) {
  t.Helper()
  // setup code that prepares the SUT
  require.NotEmpty(t, sender.ID, "sender account was incorrectly set up without an ID")
  require.NotEmpty(t, recipient.ID, "recipient account was incorrectly set up without an ID")
}

In the code snippet above, we assert the postconditions (non-empty account IDs) after the setup. These postconditions are necessary for the test to run, so it makes sense to assert them immediately.

Return early on failure

Errors may be raised in the helper. Immediately an error is detected, fail the test and return from the helper. You can fail the test with multiple error messages, but they should all be related to a single step or outcome. For example, you can report multiple precondition failures, but the step is one — the precondition check, preferably on a single input.

Returning early on failure allows the helper to generate more precise diagnostic information because it avoids the risks of further errors that would be caused by the previous errors.

func assertExpectedLoginRequestSent(t *testing.T, request *http.Request, login Login) {
  t.Helper()
  body, err := getJsonBody(request)
  if err != nil {
    t.Fail()
    return
  }
  if body != login {
    t.Fail()
    return
  }
  // more assertions?
}

In the code snippet above, we fail the test immediately after we detect an error and we return early. There is no reason to test the body if we cannot parse it.

Fail test helpers with clear error messages

Remember that the error file and line information from the helper function will be omitted when printing the test output. To avoid confusion at the call site, clearly state why the test helper failed, providing as much information as necessary, but no more.

Did you notice that the code snippet shown in the last guideline failed the test without any information about the failure? Let us fix that.

func assertExpectedLoginRequestSent(t *testing.T, request *http.Request, login Login) {
  t.Helper()
  body, err := getJsonBody(request)
  if err != nil {
    t.Failf("got bad login request body: %v", err)
    return
  }
  if body != login {
    t.Failf("got unexpected login request body: %#v != %#v", body, login)
    return
  }
  // more assertions?
}

Here is a rule of thumb: if you break out a debugger and step into a helper function to see why it failed, or you remove the t.Helper() call so that the file and line information will be printed, the helper function is not generating sufficiently helpful error messages.

Follow the single responsibility principle

Keep helper functions scoped to a single logical action. Just as a test should have only one reason to change, a test helper should have only one reason to change. In other words, test only one concern in a test case, and carry out only one action in a test helper.

The reason for this is maintainability. If a test helper can change for multiple reasons, it does not help with defect localization and understanding. Worse, the effects multiply through every test that calls that helper. For a single-focus test helper with N call sites, we have N possible tests to change if it needs to change. We can reduce those N changes by writing a new version of the helper.

For a test helper having N call sites and M concerns, the probability of changing the call sites if any of the concerns change is M*N, even for call sites that may not need all M concerns. We have to either work those call sites to be aware of the concerns that they do not need, thus breaking encapsulation, or change them for the concerns that they do not need.

Helper functions are not for reducing duplication

It is important to note that helper functions carry out actions that are bound to the lifecycle of the test or its results. If it were not so, we would not need the *testing.T parameter to the test helper function. Where reducing duplication is the only concern, reduce the number of decisions you need to make by avoiding t.Helper().

Here is a bonus guideline: A regular helper function that may fail should return an error; a test helper function that may fail should assert its failure using the t.Fail* methods and return no error.

A guideline about the guidelines

I shall close this by saying that, above all else, your good judgment is a better guide than lines of text in an article. These guidelines are not definitive, they are rules of thumb that you should consider when you need them. You are welcome to use them, break them, share them with your team, send me feedback on them, or ignore them.