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
}
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")
}
}
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
}
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
}
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.
})
}
In this example, Spy
lets you:
- Define expectations (e.g., "a
t.Error()
should be called", "a message should be logged"). - Run the helper with a controlled test manager.
- 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!
Top comments (0)