When the same assertion pattern repeats across a dozen tests, extracting it into a function is the obvious move. The problem is that a naive helper function buries the failure: t.Errorf reports the line number where it was called, which is the helper, not the test. t.Helper() fixes that by telling the test runner to skip the current function when picking a line number to blame, so failures point at the test that called the helper.
Calling t.Helper() at the top of a function marks it as a test helper. When the framework later prints a failure location, it walks up the call stack and reports the first frame that isn't marked as a helper. The actual logic of Error, Errorf, Fatal, and Fatalf is identical; only the reported file and line change.
Here's the difference in one example.
The first failure shows the line inside assertCartTotalBad, where t.Errorf was called. The second failure shows the line inside TestCartTotalReporting, where assertCartTotal was called. The second one is what you actually want: clicking the line in your editor takes you to the failing test, not to the assertion helper that ten different tests share.
The frame-skip rule is simple. The runner walks up the call stack, and the first frame whose function did NOT call t.Helper() is the one it reports. Mark every helper in the chain, and the reported line lands at the test boundary.
If you forget t.Helper() in any frame, the blame stops there. So the rule is: every function that exists to be called from a test, with *testing.T (or testing.TB) as a parameter, should start with t.Helper().
Most assertion helpers are variations on "compare two values, fail if they differ". A generic assertEqual is the most common starting point. With Go's generics (1.18+), you can write one function that works for any comparable type.
The label parameter pays its way the first time a failing test has two assertEqual calls in the same function. Without a label, the failure message just says "got 41, want 42", and you have to count lines to find which assertion fired. With a label, the failure says "Stock: got 41, want 42", which is enough.
For non-comparable types (slices, maps, structs containing those), assertEqual won't compile, because the comparable constraint excludes them. The standard fix is to use reflect.DeepEqual:
reflect.DeepEqual handles arbitrary nested structures: slices of structs, maps of slices, anything you can build. The %#v verb prints Go syntax, so the failure message shows enough detail to spot the difference. For very large or complex values, the github.com/google/go-cmp/cmp package produces nicer diffs, but reflect.DeepEqual is enough for most assertions and ships in the standard library.
reflect.DeepEqual walks the entire structure, which is fine for tests but should never appear in production hot paths. The reflection-based traversal allocates and compares one field at a time.
The other staple assertion is checking error returns. Three patterns cover almost every case: expect no error, expect any error, and expect a specific error.
All four use t.Fatal rather than t.Error. That choice is deliberate. If a test expects no error and got one, the subsequent assertions usually depend on the call having succeeded, so continuing past the error would produce a cascade of confusing failures. The same logic runs in reverse: if a test expected an error and got none, continuing on would dereference whatever value was returned, possibly panicking on a nil pointer.
Here's the pattern in action against a real function:
Each test reads as three lines of intent: do the thing, check the error, check the state. The helper calls hide the boilerplate so the test body matches the test name. When Reserve returns a fmt-wrapped error, use assertErrorContains; for sentinel errors like ErrOutOfStock, assertErrorIs is more precise. The errors.Is chain follows wrapped errors, so a caller that returns fmt.Errorf("reserve failed: %w", ErrOutOfStock) would still match.
A C-style helper might return a bool or an error and let the caller decide what to do. Go test helpers don't follow that pattern. They call t.Error or t.Fatal directly and return nothing.
The reason is symmetry with the test runner. t.Fatal doesn't propagate; it marks the test failed and calls runtime.Goexit() to unwind. If a helper returned an error and let the caller decide, every test would end with if err := assertSomething(...); err != nil { t.Fatal(err) }, which is the boilerplate the helper was supposed to eliminate. Returning nothing and calling t.Fatal inside the helper is the idiomatic shape.
The second form forces every caller to repeat the error check. The first form puts that boilerplate inside the helper once. For test code, the first form wins. For non-test code (real production code), returning errors is the right call, because the caller has actual choices to make. The distinction is: helpers run inside the test framework, so they can use the framework's failure machinery directly.
A helper is worth extracting when you've written the same assertion shape three times. Once is a fluke. Twice is a hint. Three times is a pattern.
Pulling out assertCartTotal after the first test is over-engineering. Pulling it out after the third is on time. The benefit is real: each test loses two lines of boilerplate and gains a self-explanatory function call. The cost is the indirection. The break-even is around three uses.
The same threshold applies to setup helpers. A function that builds a test inventory, opens a test database, or constructs a test request becomes worth extracting once the same three lines appear in three places.
Helpers can call other helpers, as long as each one calls t.Helper(). The frame-skip rule walks past every helper in the chain and lands at the first non-helper frame.
assertOrderValid calls assertOrderStatus and assertOrderTotal, each of which calls assertEqual. All four mark themselves as helpers. If the total were off, the failure would point at the assertOrderValid(t, o) line in TestOrderValid, not at any of the intermediate helpers.
The pattern composes well: low-level helpers (assertEqual, assertNoError) handle one concern, mid-level helpers (assertOrderStatus, assertCartTotal) handle one field, and high-level helpers (assertOrderValid) tie a few of those together for common scenarios. Pick the level that matches what the test is actually saying.
Cost: Every t.Helper() call is cheap (it sets a flag on the test), but each function call has its own stack frame. For helpers called millions of times in a benchmark loop, inline the comparison. For ordinary tests, the cost is negligible.
*testing.T and *testing.B both satisfy the testing.TB interface, which includes Helper, Error, Errorf, Fatal, Fatalf, Log, Logf, and Cleanup. A helper that takes testing.TB works from a test (*testing.T), a benchmark (*testing.B), or a fuzz target (*testing.F) without any changes.
Taking testing.TB is the right default for any helper you might call from more than a test, and it costs nothing if you only ever use it in tests. The handful of methods missing from the interface (Parallel, Run, Deadline) are test-specific anyway. If a helper needs one of those, it has to take *testing.T directly.
A practical example: a helper that builds a stock inventory for tests is also useful inside benchmarks that measure inventory operations. Taking testing.TB means you don't write two versions.
The helper pattern carries over to benchmarks without changes.
Helpers that allocate resources (temp files, HTTP servers, database connections) should register their cleanup via t.Cleanup (or tb.Cleanup). This keeps the lifecycle inside the helper and out of the test body.
The cleanup is registered against the tb passed in, which is the test's own TB. When the test ends, the cleanup fires, no matter how the test ended. This is much cleaner than returning a cleanup func() for the caller to defer; the helper owns the lifecycle from creation to teardown.
Two patterns to avoid. First, don't defer cleanup inside the helper, because defer fires when the helper returns, which is too early. The test still needs the file. Second, don't return a cleanup function and trust the caller to defer it, because every caller will eventually forget. tb.Cleanup fits for the same reason t.Cleanup exists.
Not every repeated line deserves a helper. A few patterns where inline code beats extraction:
c := Cart{Items: []float64{4.99}} doesn't need a newCartWithOneItem function. The literal is shorter than the call.verifyEverything(t, x) that runs ten unrelated assertions makes failures hard to diagnose. Pick a narrower scope.The rule of three is the floor, not the ceiling. Once you have a helper, ask whether it's actually being reused or whether it's just sitting in the codebase. Helpers that get exactly one call site are usually a sign you extracted too early.
Here's what a small, idiomatic test file looks like once helpers are in place. The helpers live at the top, the tests read like specifications.
Each test reads as: build, exercise, assert. The helpers handle the noise, the test names describe the scenario, and any failure points at the exact assertion line in the test body. That's the payoff for one-time investment in a small set of well-chosen helpers.
10 quizzes