29.1 Writing Tests: func TestXxx(t *testing.T)
Right, so you’ve decided to write a test. Good for you. It’s the responsible thing to do, like flossing or not putting off that oil change. And in Go, the entry point for this particular brand of responsibility is a function named TestXxx, where Xxx is anything that doesn’t start with a lowercase letter. It’s not a suggestion; it’s how the go test command finds your work. You’ll be handed a *testing.T—think of it as your all-access pass to the test framework, your bullhorn for shouting about failures, and your notepad for logging what the heck is going on.
The signature is non-negotiable: func TestAddUser(t *testing.T). Mess this up, and your test will sit there like a forgotten parcel, never to be executed. The t parameter is your control panel. You’ll use t.Error or t.Fatal to report failure, t.Log to leave yourself notes, and a whole suite of other methods for more complex scenarios.
Let’s start with the most basic, almost naive, version of a test. Imagine we have a function we need to test in math.go:
// math.go
package main
func Add(a, b int) int {
return a + b
}
The test for it, living in math_test.go, would look like this:
// math_test.go
package main
import "testing"
func TestAdd(t *testing.T) {
got := Add(2, 2)
want := 4
if got != want {
t.Errorf("Add(2, 2) = %d; want %d", got, want)
}
}
Run it with go test. If it passes, you get a satisfying little PASS and a hit of dopamine. If it fails, t.Errorf does two things: it marks the test as failed and logs your custom error message. It doesn’t stop the test, though. If subsequent lines could crash or cause confusion after a failure, use t.Fatalf instead to bail out immediately.
The Glorious Power of Table-Driven Tests
Writing one test for one input is fine for a hello world, but it’s also how you end up with a sprawling, unmaintainable mess of copy-pasted functions. Enter the table-driven test, which is less of a “table” and more of a “slice of anonymous structs that we iterate over.” It’s the only sane way to write tests in Go, and anyone who tells you differently is probably also using goto statements.
The pattern is simple: define your test cases as a slice, then loop over them. This consolidates all your test logic for one function into a single, coherent Test function.
func TestAdd_TableDriven(t *testing.T) {
tests := []struct {
name string
a int
b int
want int
}{
{"positive", 2, 3, 5},
{"negative", -1, -1, -2},
{"zero", 0, 5, 5},
{"positive_negative", 5, -3, 2},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := Add(tt.a, tt.b)
if got != tt.want {
t.Errorf("Add(%d, %d) = %d; want %d", tt.a, tt.b, got, tt.want)
}
})
}
}
Why t.Run and Subtests Are a Game Changer
Notice that t.Run call in the loop? This isn’t just ceremony. Each t.Run creates a formal subtest. Before subtests, a failure in a table-driven test would just tell you the line number where the error occurred, leaving you to figure out which case in your table was the culprit. It was a pain.
Now, go test -v will output each subtest’s name (positive, negative, etc.) as a distinct test. If the “positive_negative” case fails, you know exactly which one it is without any debugging. You can even run a specific subtest: go test -run TestAdd_TableDriven/positive_negative. This is invaluable for isolating a failure. The name field isn’t a cute comment; it’s a crucial identifier. Make it descriptive.
Logging: For When You Need to Actually See What’s Happening
When a test fails, the default error message might not be enough. This is where t.Log and t.Logf come in. These messages are only visible when running with the -v flag (or when the test fails in some Go versions), which keeps your terminal clean during a normal test run but gives you a detailed audit trail when you need it.
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := Add(tt.a, tt.b)
t.Logf("Testing %d + %d, expecting %d", tt.a, tt.b, tt.want) // Only shown with -v or on failure
if got != tt.want {
t.Errorf("Got %d, wanted %d", got, tt.want)
}
})
}
The One Big Pitfall: Variable Scoping in Loops
This is the classic gotcha that gets everyone eventually. Let’s look at a dangerous, broken version of our loop:
for _, tt := range tests {
// WARNING: DO NOT DO THIS. tt is getting reused on each iteration.
go func() {
t.Run(tt.name, func(t *testing.T) { // tt inside this closure is a moving target!
got := Add(tt.a, tt.b)
if got != tt.want {
t.Errorf("Add(%d, %d) = %d; want %d", tt.a, tt.b, got, tt.want)
}
})
}()
}
If you run this with some parallelism, you’ll likely find all your subtests are running with the values from the last element in the tests slice. Why? The closure (the anonymous function) captures the variable tt, not its value at the time the goroutine is launched. By the time the goroutine executes, the loop has likely already finished, and tt is set to its final value.
The fix is simple: shadow the variable inside the loop to ensure each closure gets its own copy.
for _, tt := range tests {
tt := tt // This line is critical if you're using goroutines!
go func() {
t.Run(tt.name, func(t *testing.T) {
// Now this tt is the correct one for this iteration
got := Add(tt.a, tt.b)
if got != tt.want {
t.Errorf("Add(%d, %d) = %d; want %d", tt.a, tt.b, got, tt.want)
}
})
}()
}
In a standard table-driven test without goroutines, the t.Run call is synchronous, so this isn’t an issue. The moment you introduce concurrency, you must remember to shadow the variable. It’s a rite of passage. Welcome to the club.