Test Helpers
You've written table-driven tests — now let's clean up the repetition.
Once a test file has more than a couple of cases, you'll notice the same
if got != want { t.Errorf(...) } shape appearing over and over. The fix
is an assertion helper — but a naive one makes debugging worse, not
better. This lesson is about doing it right.
The repetition problem
Say three different tests all check "got equals want" with a slightly
different message each time. Copy-pasting that if block means a typo in
one copy doesn't show up in the others, and changing the message format
means editing every call site. The obvious move is to extract a function:
func checkEqual(t *testing.T, got, want int) {
if got != want {
t.Errorf("got %d, want %d", got, want)
}
}
That works — until it fails. The failure line main_test.go:12: points at
the t.Errorf call inside checkEqual, not at the test that called it.
With ten call sites, "line 12" tells you nothing about which one broke.
t.Helper() fixes the line number
Add t.Helper() as the first line of the function:
func checkEqual(t *testing.T, got, want int) {
t.Helper()
if got != want {
t.Errorf("got %d, want %d", got, want)
}
}
t.Helper() marks the current function as a test helper. When a failure
is later reported from inside it, go test walks back up the call stack
and attributes the failure to the caller's line instead — exactly where
you'd look first. Call it unconditionally, as the very first statement,
before any early returns.
t.Cleanup vs defer
You already use defer to tear down resources inside a single test
function. t.Cleanup(func()) does something similar but registers the
function with the test itself, not the enclosing scope. Two differences
matter:
- Cleanups run in LIFO order after the test (and its subtests) finish —
including subtests started with
t.Run— even if the test callst.Fatalpartway through. - You can call
t.Cleanupfrom inside a helper function that returns before the test does. Adeferin that helper would fire immediately when the helper returns;t.Cleanupwaits for the whole test.
That second point is why setup helpers use t.Cleanup instead of handing
back a teardown function for the caller to defer:
func newTempFile(t *testing.T) *os.File {
t.Helper()
f, _ := os.CreateTemp("", "test")
t.Cleanup(func() { os.Remove(f.Name()) })
return f
}
testing.TB — and why you can't implement it
*testing.T and *testing.B share a common interface, testing.TB, so
helpers can accept either. You'd think you could write your own type that
satisfies it for use outside real tests — but testing.TB deliberately
embeds an unexported method. That's Go's way of sealing an interface: only
the testing package can produce values that satisfy it. You cannot
implement testing.TB yourself, on purpose.
The practical answer isn't to fight that — it's to not need testing.TB
at all. Define your own minimal interface with just the methods your
helper actually uses:
type TB interface {
Helper()
Errorf(format string, args ...any)
}
Interfaces in Go are satisfied implicitly (you met this back in Part 3):
*testing.T already has Helper() and Errorf(), so it satisfies your
TB without testing knowing your interface exists. Your helper takes
TB, real tests pass t, and nothing needs to import anything special.
Testing your helpers with a fake
The other benefit of a small local interface: you can implement it
yourself for testing. A recordingTB that counts Helper() calls and
stores every Errorf message lets you write tests for your assertion
helpers — confirming they call Helper() and word failures usefully,
without a real test ever needing to fail on purpose. That's the pattern
the exercise asks you to build against.
Practice
1. Assert Yourself
IntermediateAssertion helpers shrink repetitive test code — but a helper that reports failures on ITS own line number is worse than none.
t.Helper()fixes that.One catch: you can't implement
testing.TByourself (it embeds an unexported method, on purpose). So this exercise defines a tiny local interface —TBwith justHelper()andErrorf— that*testing.Tsatisfies implicitly. Part 3 paying off.Implement both helpers:
Equal[T comparable](tb TB, got, want T)— calltb.Helper()first; ifgot != want, report viatb.Errorfwith a message containing both values.NotEqual[T comparable](tb TB, got, want T)— the inverse: fail when they're equal.
The hidden tests drive your helpers with a fake TB that records every call — so they can check you called Helper() and worded the failure usefully.
package main import "fmt" // TB is the small slice of testing.TB that assertion helpers need. // *testing.T satisfies it automatically — interfaces are satisfied // implicitly, so testing doesn't need to know this interface exists. type TB interface { Helper() Errorf(format string, args ...any) } // Equal reports a failure through tb when got != want. func Equal[T comparable](tb TB, got, want T) { // TODO: // 1. tb.Helper() so the failure line points at the CALLER // 2. compare // 3. tb.Errorf with BOTH values } // NotEqual reports a failure through tb when got == want. func NotEqual[T comparable](tb TB, got, want T) { // TODO: // 1. tb.Helper() so the failure line points at the CALLER // 2. compare // 3. tb.Errorf with BOTH values } func main() { fmt.Println("assertion helpers ready — run the tests") }Sign in to write and run your solution in the browser.
Sign in for hints and the solution.