DEV Community

Cover image for Testing Go Test Helpers
Rafal Zajac
Rafal Zajac

Posted on

Testing Go Test Helpers

If you've spent any time writing Go tests, you've probably encountered the joy of *testing.T. It's the backbone of Go's testing framework  -  powerful, flexible, and ubiquitous. But as your test suite grows, you might find yourself repeating the same chunks of test logic across multiple test cases. To streamline your tests, improve readability, and reduce complexity you decide to move those chunks to reusable functions called test helpers. Various assert libraries are prime examples of test helpers, turning verbose checks into concise assertions.

But here's the catch: how do you test the test helpers themselves? After all, these are the tools you rely on to ensure your code works as expected. If they fail, your tests might silently lie to you. This is where the tester package comes to the rescue. In this article, we'll explore why testing test helpers matters and how the tester package makes it possible - and even enjoyable.

Test Helpers Are Code Too

Let's start with a simple example. Imagine you've written a test helper to check if a number is odd:

// IsOdd asserts "have" is odd number. Returns true if it is,
// otherwise marks the test as failed, writes error message to 
// the test log and returns false.
func IsOdd(t *testing.T, have int) bool {
  t.Helper()
  if have%2 == 0 {
    t.Errorf("expected %d to be odd", have)
    return false
  }
  return true
}
Enter fullscreen mode Exit fullscreen mode

It takes a *testing.T instance (aka test manager, or test context), marks itself as a helper with call to t.Helper() method, and either returns true on success or logs an error or returns false based on the input. It's simple, reusable, and makes your tests cleaner. You might use it like this:

func Test_Something(t *testing.T) {
  if !IsOdd(t, 3) {
    t.Log("additional log message or more assertions")
  }
}
Enter fullscreen mode Exit fullscreen mode

Great, right? But what if there's a bug in IsOdd? Maybe the modulo check is backwards, or the error message is misleading. How do you ensure this test helper behaves as expected? You can't just trust it  -  you need to test it. This is tricky because *testing.T is deeply tied to the Go test runner, making it impossible to mock or inspect its behavior programmatically.

The tester Package

The tester package solves this problem by introducing two key components: the T interface and the Spy struct. Together, they let you test your test helpers with precision and confidence.
The tester.T interface is a curated subset of *testing.T's methods - things like Errorf, Fatal, Log, Helper, Cleanup and others to allow mocking.

// T is a subset of [testing.TB] interface.
type T interface {
   Cleanup(func())
   Error(args ...any)
   Errorf(format string, args ...any)
   Fatal(args ...any)
   Fatalf(format string, args ...any)
   FailNow()
   Failed() bool
   Helper()
   Log(args ...any)
   Logf(format string, args ...any)
   Name() string
   Setenv(key, value string)
   Skip(args ...any)
   TempDir() string
   Context() context.Context
}
Enter fullscreen mode Exit fullscreen mode

Any test helper that works with *testing.T can work with tester.T, as long as it sticks to methods supported by tester.T. This means you can test IsOdd helper by first refactoring it to use tester.T:

// IsOdd asserts "have" is odd number. Returns true if it is,
// otherwise marks the test as failed, writes error message to 
// the test log and returns false.
func IsOdd(t tester.T, have int) bool {
  t.Helper()
  if have%2 == 0 {
    t.Errorf("expected %d to be odd", have)
      return false
    }
  return true
}
Enter fullscreen mode Exit fullscreen mode

Then writing tests for it using Spy instance.

Spy Your Test Helpers

The real magic happens with tester.Spy. This struct implements tester.T and acts as a spy  -  it tracks every interaction your test helper has with the test manager. Want to know if Helper() was called? If an error was logged? If a cleanup function was registered? Spy has you covered.

Here's how you might test IsOdd using Spy:

func Test_IsOdd(t *testing.T) {
  t.Run("error is not odd number", func(t *testing.T) {
    // --- Given ---

    // Set up the spy with expectations
    tspy := tester.New(t)
    tspy.ExpectError()                              // Expect an error.
    tspy.ExpectLogEqual("expected %d to be odd", 2) // Expect log.
    tspy.Close()                                    // No more expectations.

    // --- When ---
    success := IsOdd(tspy, 2) // Run the helper.

    // --- Then ---
    if success { // Verify the outcome.
      t.Error("expected success to be false")
    }
    tspy.AssertExpectations() // Ensure all expectations were met.
  })

  t.Run("success is odd number", func(t *testing.T) {
    // Given
    tspy := tester.New(t)
    tspy.Close()

    // When
    success := IsOdd(tspy, 3)

    // Then
    if !success {
      t.Error("expected success to be true")
    }

    // The `tspy.AssertExpectations()` is called automatically.
  })
}
Enter fullscreen mode Exit fullscreen mode

In this example, Spy lets you:

  1. Define expectations (e.g., "a t.Error() should be called", "a message should be logged").
  2. Run the helper with a controlled test manager.
  3. Verify both the helper's return value and its interactions with the test manager.

If IsOdd doesn't behave as expected  -  say, it forgets to call t.Helper() or logs the wrong message  -  tspy.AssertExpectations() will fail the test, pinpointing the issue.

Why This Matters

You might be thinking: "Do I really need to test my test helpers? They're simple!" But simplicity doesn't guarantee correctness. Test helpers are code, and code can have bugs. A subtle mistake in a helper could cascade across dozens of tests, leading to false positives (passing tests that should fail) or false negatives (failing tests that should pass). By testing your helpers, you build a stronger foundation for your entire test suite.

The tester package makes this process practical:

  • Flexibility: The T interface ensures your helpers are portable and testable.
  • Precision: Spy lets you set fine-grained expectations, from method calls through log messages to cleanup calls.
  • Confidence: You can trust your helpers because you've verified their behavior.

Beyond the Basics

The tester package doesn't stop at simple error checks. With Spy, you can:

  • Verify cleanup functions are registered and executed (ExpectCleanups, Finish).
  • Match log messages with exact strings, substrings, or regex patterns (ExpectLogEqual, ExpectLogContain, ExpectLogNotContain).
  • Inspect temporary directories created by TempDir (GetTempDir).
  • Even ignore logs when they're irrelevant (IgnoreLogs).
  • And many more.

This depth makes it invaluable for testing complex helpers  -  think assertion libraries or setup/teardown utilities.

Test Smarter, Not Harder

Testing test helpers might sound like overkill, but it's a small investment with a big payoff. The tester package empowers you to treat your helpers as first-class citizens in your codebase, ensuring they're as reliable as the code they test. Next time you write a helper, don't just use it  -  test it with tester. Your future self (and your test suite) will thank you.

Want to dive deeper? Check out the full documentation for the tester package and start spying on your test helpers today!

Sentry image

See why 4M developers consider Sentry, “not bad.”

Fixing code doesn’t have to be the worst part of your day. Learn how Sentry can help.

Learn more

Top comments (0)

Image of Datadog

The Essential Toolkit for Front-end Developers

Take a user-centric approach to front-end monitoring that evolves alongside increasingly complex frameworks and single-page applications.

Get The Kit

👋 Kindness is contagious

Please leave a ❤️ or a friendly comment on this post if you found it helpful!

Okay