Learn Go Series (#17) - Testing: Table-Driven Tests, Subtests, and t.Helper
Learn Go Series (#17) - Testing: Table-Driven Tests, Subtests, and t.Helper
What will I learn
- Go's built-in
testingpackage: writing aTestXxxfunction and running it withgo test, no framework needed; - The table-driven test, the idiom you will use more than any other in Go;
- Subtests with
t.Run, for named, individually-runnable cases; t.Helper, so assertion helpers report failures at the caller's line;t.Errorversust.Fatal, and how to write a clear failure message;- Where tests live, the flags worth knowing (
-v,-run,-cover), and why Go's "just write a function" approach beats heavyweight test frameworks.
Requirements
- A working modern computer running macOS, Windows or Ubuntu;
- An installed Go distribution (1.27 or newer, from go.dev/dl) -- tested against Go 1.27;
- Episodes 1-16, especially structs (episode 8) and slices (episode 5) for the table pattern;
- The ambition to learn Go programming.
Difficulty
- Intermediate
Curriculum (of the Learn Go Series):
- Learn Go Series (#1) - Introduction to Go
- Learn Go Series (#2) - Variables, Types, Constants, and iota
- Learn Go Series (#3) - Functions and First-Class Functions
- Learn Go Series (#4) - Control Flow: if, switch, and the Only Loop
- Learn Go Series (#5) - Arrays, Slices, and Slice Internals
- Learn Go Series (#6) - Maps and Sets
- Learn Go Series (#7) - Strings, Runes, and Unicode
- Learn Go Series (#8) - Structs, Methods, and Receivers
- Learn Go Series (#9) - Interfaces and Implicit Satisfaction
- Learn Go Series (#10) - Pointers, Memory, and Escape Analysis
- Learn Go Series (#11) - Error Handling the Go Way
- Learn Go Series (#12) - Packages, Modules, and Project Layout
- Learn Go Series (#13) - Goroutines and Channels: the Fundamentals
- Learn Go Series (#14) - select and the sync Toolbox
- Learn Go Series (#15) - context: Cancellation, Deadlines, and Request-Scoped Values
- Learn Go Series (#16) - Generics: Type Parameters, Constraints, and Inference
- Learn Go Series (#17) - Testing: Table-Driven Tests, Subtests, and t.Helper (this post)
Learn Go Series (#17) - Testing: Table-Driven Tests, Subtests, and t.Helper
Testing in Go is refreshingly ordinary. There is no framework to install, no special syntax, no annotations decorating your functions, no describe/it/expect vocabulary to memorise before you can check that 2 + 2 is 4. You write a function named TestSomething that takes a *testing.T, put it in a file ending _test.go, and run go test. That deliberate minimalism is the whole point, and it is very much on-brand for a language that has spent this entire series saying "no" to features other languages say "yes" to. Tests are just Go code, so everything you already know applies -- variables, slices, structs, the for loop, the comma-ok idiom -- and there is genuinely nothing extra to learn before you can verify your work. Contrast that with the ecosystems where picking a test runner is a weekend project and half your CI config is glue between it and everything else. This episode gives you the handful of patterns that cover almost all real testing in Go, and above all the table-driven test, which you will write over and over until it becomes muscle memory. First, as always, last episode's exercises.
Solutions to Episode 16 Exercises
Exercise 1 -- a generic Filter:
package main
import "fmt"
func Filter[T any](s []T, keep func(T) bool) []T {
var out []T
for _, v := range s {
if keep(v) {
out = append(out, v)
}
}
return out
}
func main() {
evens := Filter([]int{1, 2, 3, 4, 5, 6}, func(n int) bool { return n%2 == 0 })
nonEmpty := Filter([]string{"a", "", "b", ""}, func(s string) bool { return s != "" })
fmt.Println(evens, nonEmpty) // [2 4 6] [a b]
}
The key insight: Filter needs no constraint beyond any, because it never compares or adds elements -- it only calls the caller's keep function on each one, so the type parameter can be genuinely anything. Starting from a nil slice and append-ing is idiomatic; a filtered-out-everything call returns an empty (well, nil) slice rather than crashing.
Exercise 2 -- a generic Min with cmp.Ordered:
package main
import (
"cmp"
"fmt"
)
func Min[T cmp.Ordered](s []T) (T, bool) {
var zero T
if len(s) == 0 {
return zero, false
}
m := s[0]
for _, v := range s[1:] {
if v < m {
m = v
}
}
return m, true
}
func main() {
fmt.Println(Min([]int{4, 2, 9, 1})) // 1 true
fmt.Println(Min([]string{"pear", "apple"})) // apple true
fmt.Println(Min([]int{})) // 0 false
}
The insight here is the (T, bool) return paired with var zero T: an empty slice has no minimum, so instead of panicking or inventing a value we return the zero value and false, the same comma-ok shape maps and channels use. cmp.Ordered is what unlocks the < comparison inside the loop.
Exercise 3 -- a generic Set:
package main
import "fmt"
type Set[T comparable] struct {
m map[T]struct{}
}
func NewSet[T comparable]() *Set[T] {
return &Set[T]{m: make(map[T]struct{})}
}
func (s *Set[T]) Add(v T) { s.m[v] = struct{}{} }
func (s *Set[T]) Contains(v T) bool { _, ok := s.m[v]; return ok }
func (s *Set[T]) Len() int { return len(s.m) }
func main() {
ints := NewSet[int]()
ints.Add(1)
ints.Add(1) // duplicate ignored
ints.Add(2)
fmt.Println(ints.Len(), ints.Contains(2)) // 2 true
strs := NewSet[string]()
strs.Add("go")
fmt.Println(strs.Contains("rust")) // false
// T must be comparable because map keys require == and !=.
}
The insight: the element type must be comparable, not any, because the whole implementation leans on a map[T]struct{}, and map keys need == and !=. The empty-struct value costs zero bytes, which is exactly why it is the idiomatic set-membership marker. Now, testing.
Your first test
A test is a function func TestXxx(t *testing.T) living in a file named *_test.go. You call the code under test, and if the result is wrong you report it with t.Errorf. There are no assertion macros and no expect(x).toBe(y) fluent chains -- you write a plain if and fail with a message you wrote yourself. That feels primitive the first time you see it, and then it quietly becomes a feature: the failure message says exactly what you meant, because you wrote it, not some library's best guess. (In a real project Add would live in calc.go and the test in calc_test.go; here they share one file so the example is self-contained and you can paste it straight into a scratch package.)
package calc
import "testing"
func Add(a, b int) int { return a + b }
func TestAdd(t *testing.T) {
got := Add(2, 3)
want := 5
if got != want {
t.Errorf("Add(2, 3) = %d; want %d", got, want)
}
}
Run it with go test in that directory, or go test ./... from the module root to run every package's tests at once. Add -v and you see each test name and a PASS/FAIL line; leave it off and Go stays quiet unless something breaks, which is the right default for a big suite. The got/want naming is a convention worth adopting from your very first test, because it makes every message read the same way across a whole codebase. And the message format -- "Add(2, 3) = %d; want %d", showing the call, what you got, and what you wanted -- is not decoration. When a test fails in CI at 2am and you are staring at a log, that one line is all you have; a message that says "test failed" is worthless, and a message that says Add(2, 3) = 6; want 5 tells you the bug in one glance. No framework, no ceremony, no configuration file: a function, an if, and a clear message.
The table-driven test
Almost no real function deserves to be tested with a single case. You want the boundary values, the zero, the negatives, the empty input, the one that used to be a bug -- and writing a separate TestAddPositives, TestAddZero, TestAddNegatives for each is tedious and, worse, spreads the cases out where you cannot see them together. The Go idiom is a table: a slice of anonymous structs, one element per case, and a single loop that runs each. Adding a new case is one new line, and all the cases sit together where you can read the coverage at a glance:
package calc
import "testing"
func Add(a, b int) int { return a + b }
func TestAddTable(t *testing.T) {
cases := []struct {
name string
a, b int
want int
}{
{"positives", 2, 3, 5},
{"with zero", 0, 7, 7},
{"negatives", -4, -6, -10},
}
for _, c := range cases {
if got := Add(c.a, c.b); got != c.want {
t.Errorf("%s: Add(%d, %d) = %d; want %d", c.name, c.a, c.b, got, c.want)
}
}
}
This is the single most common shape of Go test in existence, and you should reach for it by reflex. The anonymous struct type declared right there inline is doing real work: each case carries a name so a failure is identifiable, the inputs, and the expected output, all in one tidy row. Notice how the message prefixes c.name so that when the negatives row fails you know instantly which row, without counting brackets. The deeper payoff shows up over the life of a project: when a bug is found, you do not write a new test -- you add the failing input as one more row, and the table slowly grows into a permanent record of every case that ever mattered. A well-loved function ends up with a table of twenty rows that reads like a little museum of past mistakes, each one now guarded forever. Get comfortable with this pattern and you have, honestly, most of Go testing. Everything else is refinement on top of it.
Subtests with t.Run
The table above has one small weakness: it is a single test to Go's eyes, so one failing row does not stop the loop (good) but you also cannot run just one row, and the output does not treat the cases as first-class. t.Run(name, func) fixes that by creating a subtest -- a named child test that reports independently and can be selected on its own. Combine it with the table and each case becomes its own little test:
package calc
import "testing"
func Add(a, b int) int { return a + b }
func TestAddSubtests(t *testing.T) {
cases := []struct {
name string
a, b int
want int
}{
{"positives", 2, 3, 5},
{"with zero", 0, 7, 7},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) { // each case is an isolated subtest
if got := Add(c.a, c.b); got != c.want {
t.Errorf("got %d; want %d", got, c.want)
}
})
}
}
Look at what this buys you. Each case now runs inside its own *testing.T, so go test -run TestAddSubtests/with_zero runs exactly that one row and nothing else -- priceless when a suite takes minutes and you are debugging a single case (spaces in a subtest name become underscores in the -run path, which is why it is with_zero and not with zero). The output with -v names each subtest, so a failure reads --- FAIL: TestAddSubtests/with_zero, pointing straight at the culprit. And because the message inside the closure no longer needs to re-state the case name (the subtest already carries it), the assertion gets shorter and cleaner. This table-plus-t.Run combination is the mature, grown-up form of the pattern -- the table holds the data, the subtests give it structure -- and it is exactly what you will find if you go read the standard libary's own tests, which are a masterclass worth an afternoon of your time.
t.Helper: point failures at the caller
Once you write more than a few assertions you will feel the urge to factor a repeated check into a helper function -- and you should, that instinct is correct. The problem is that a naive helper reports its failures at its own line, which is nearly useless: call the helper from ten places, and every failure blames the one t.Errorf line inside the helper instead of the call that actually went wrong. Go's answer is one line. Calling t.Helper() at the top of the helper marks it, so the testing machinery skips it when working out where to report the failure, and blames the line that called the helper instead:
package calc
import "testing"
func Add(a, b int) int { return a + b }
// assertEqual is a test helper; t.Helper() makes failures point at the caller.
func assertEqual(t *testing.T, got, want int) {
t.Helper()
if got != want {
t.Errorf("got %d; want %d", got, want)
}
}
func TestWithHelper(t *testing.T) {
assertEqual(t, Add(2, 2), 4)
assertEqual(t, Add(10, 5), 15)
}
Without t.Helper(), both assertions above would report failures at the same interior line of assertEqual, and you would have no idea whether it was the 2+2 call or the 10+5 call that broke. With it, the failure names the specific assertEqual(...) line in TestWithHelper -- exactly where you need to look, no detective work required. The rule is simple and absolute: any time you write a test helper that can call t.Error/t.Fatal, put t.Helper() as its first statement. It is one line, it never hurts, and it saves you real debugging time on the day you least want to spend it. This is also how you build your own tiny assertion vocabulary without importing a library -- an assertEqual, an assertNil, an assertSliceEqual for the types you test most, each with t.Helper() up top, and suddenly your tests read almost as fluently as the framework-heavy ones, with none of the dependency.
t.Error versus t.Fatal
There are two ways to fail a test, and choosing correctly is a small skill worth getting right early. t.Error/t.Errorf records the failure but lets the test keep running, so a single run can report several independent problems at once. t.Fatal/t.Fatalf records the failure and stops that test immediately (via a runtime.Goexit, for the curious) -- you use it when continuing makes no sense, typically after a setup step that must succeed before anything else can even be attempted:
package store
import "testing"
func New() map[string]int { return make(map[string]int) }
func TestFatalVsError(t *testing.T) {
m := New()
if m == nil {
t.Fatal("New returned nil; the rest of the test cannot run") // stop now
}
m["a"] = 1
if m["a"] != 1 {
t.Error("expected a == 1") // record, but keep checking
}
if len(m) != 1 {
t.Error("expected length 1")
}
}
The mental model: use t.Fatal for preconditions where carrying on would just crash with a nil-pointer panic or spew a page of nonsense follow-on failures (a failed setup, a nil value you are about to dereference, an error opening the fixture you were going to read). Use t.Error for independent assertions where you would genuinely rather see all the failures from one run, because fixing three at once beats the run-fix-rerun-fix loop three times over. Having said that, there is one sharp edge that catches people, and it is worth burning into memory now: t.Fatal must be called from the test goroutine, not from some helper goroutine you spawned with go. Calling Fatal from a goroutine you started only exits that goroutine, leaving the test to sail on with a false pass. In concurrent tests -- and after episodes 13 through 15 you will write plenty -- the discipline is to have the worker goroutine send an error back over a channel and let the main test goroutine be the one that calls Fatal on it.
A few flags and where tests live
Before we compare notes with Python, a quick tour of the plumbing, because a testing episode that never mentions how to run the things would be a bit silly. Tests live in the same package as the code they test (package calc in calc_test.go), which lets them reach unexported functions and internals -- white-box testing by default. If you instead want to test only the public surface, as a user of the package would, name the test package calc_test (the _test suffix is special and Go allows exactly this one external variant to sit alongside the real package) and you get black-box tests that import calc like anyone else. Both styles are common and you will mix them.
For running, the flags you will actually use day to day are few: go test -v for verbose per-test output, go test -run TestAdd to run tests whose name matches a regexp (and -run TestAdd/negatives to drill into a subtest, as we saw), go test -cover for a quick coverage percentage, and go test -race to turn on the race detector we keep foreshadowing. There is no separate config file, no runner to install, no plugin ecosystem to keep up with -- go test is part of the toolchain you already have. That "it is just there" quality is easy to undervalue until you have fought with the alternative in another language.
The same idea in Python
Python ships unittest in its standard library (class-based, with assertEqual, assertTrue, and friends inherited from a TestCase), while the wider ecosystem overwhelmingly reaches for pytest (plain functions and a bare assert, with a lot of clever machinery to make that assert produce good messages). Go sits closer to pytest's spirit -- plain functions, no class ceremony -- but with two deliberate differences: no third-party dependency at all, and no rich assertion library, so you write the comparisson yourself with an if:
import pytest
def add(a, b):
return a + b
@pytest.mark.parametrize("a,b,want", [
(2, 3, 5),
(0, 7, 7),
(-4, -6, -10),
])
def test_add(a, b, want):
assert add(a, b) == want
pytest.mark.parametrize is Python's table-driven test, and it is genuinely lovely -- arguably more concise than Go's hand-rolled slice-of-structs, and pytest's rewritten assert gives you a good failure message for free. Go's version is more code: a struct type, a slice literal, a loop, a message you write by hand. But you get zero dependencies, the full power of the language available inside the test (no special DSL that only works in the test file), and total control over the message. It is the same philosophy that has run through this whole series: Go would rather you write a little more and understand exactly what it does than lean on magic you did not write. Tests are ordinary code, the standard tool is enough, and the pattern you build by hand is one you fully own.
Exercises
Table-test a function of your own. Write
func IsPalindrome(s string) bool(reuse the rune-reverse idea from episode 7), and a table-driven test covering an empty string, a single character, a true palindrome, and a non-palindrome. Uset.Runso each case is a named subtest, and check thatgo test -run TestIsPalindrome/emptyruns just the one row.A helper that reports correctly. Write
func Reverse(s []int) []intand a test with a helperassertSlicesEqual(t *testing.T, got, want []int)that callst.Helper()and fails with a message showing both slices. Confirm (by deliberately breakingReverse) that the failure points at the calling line, not at the interior of the helper.Fatal for setup, Error for checks. Write a test that builds a small map via a constructor,
t.Fatals if the constructor returns nil, and then makes two independentt.Errorchecks on the contents. Explain in a comment why the first usesFataland the others useError.
What we learned
- A Go test is a plain
func TestXxx(t *testing.T)in a*_test.gofile, run withgo test-- no framework, no annotations, just code and a cleargot/wantfailure message; - The table-driven test (a slice of case structs plus a loop) is the dominant idiom: each row names its case, inputs, and expected output, and a found bug becomes one new row -- a growing record of every case that ever mattered;
- Subtests via
t.Run(name, func)give named, isolated, individually-runnable cases (go test -run TestX/case) -- table-plus-t.Runis the mature form of the pattern; t.Helper()at the top of an assertion helper makes failures report at the caller's line, not the helper's -- always add it, it is one line;t.Errorrecords a failure and continues (see all problems at once);t.Fatalrecords and stops the test (for preconditions), and must be called from the test goroutine, never a spawned one;- tests live in the same package (white-box) or a
_testsibling (black-box), and-v,-run,-cover, and-raceare the flags you will actually use; - Go's testing philosophy matches the language: tests are ordinary code, the standard
testingpackage is enough, and you write your own comparisons rather than importing an assertion library.
Next episode we stop merely checking that code is correct and start measuring how fast it is: benchmarking with go test -bench, the -race detector that finally hunts down the data races we kept warning you about, and CPU and memory profiling with pprof -- the tools that turn "it feels slow" into "here is the exact hot line". See you there.
Tot de volgende keer! ;-)