29.3 t.Run: Subtests and Parallel Subtests
Right, so you’ve written a table-driven test. It’s clean, it’s elegant, and you’re feeling pretty good about yourself. And you should. But now you run go test -v and you’re greeted with a monolithic block of output: TestMyFunction/input_1, TestMyFunction/input_2, … and when test #7 fails, you have to squint at the output to figure out which specific input scenario just blew up. And heaven forbid you want to run just that one failing scenario to debug it. You can’t. You have to run the whole table.
This is where t.Run() waltzes in, hands you a cup of coffee, and fixes your life. It lets you break that single table test into named, first-class subtests. Each one is its own little island of sanity.
The Basic Superpower: Named Subtests
The t.Run() method is your entry point. It takes a name (so you can actually identify what’s happening) and a function to execute. Wrapping each of your table entries in a subtest transforms your testing experience from a faceless mob into a orderly line of individuals.
func TestAdd(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},
{"large numbers", 1000000, 5000000, 6000000},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := Add(tt.a, tt.b); got != tt.want {
t.Errorf("Add(%d, %d) = %d, want %d", tt.a, tt.b, got, tt.want)
}
})
}
}
Now, when you run with -v, you get beautiful, granular output:
=== RUN TestAdd
=== RUN TestAdd/positive
=== RUN TestAdd/negative
=== RUN TestAdd/zero
=== RUN TestAdd/large_numbers
--- PASS: TestAdd (0.00s)
--- PASS: TestAdd/positive (0.00s)
--- PASS: TestAdd/negative (0.00s)
--- PASS: TestAdd/zero (0.00s)
--- PASS: TestAdd/large_numbers (0.00s)
More importantly, if “large_numbers” fails, you can run just that one to debug it: go test -run "TestAdd/large_numbers" -v. This is the killer feature. It makes debugging a targeted operation instead of an archaeological dig.
The Real Magic: Running Tests in Parallel
Here’s where t.Run() goes from “neat” to “non-negotiable.” Subtests can be run in parallel, which is a godsend for tests that involve sleeping, waiting on I/O, or making expensive network calls. The testing package can’t safely run your individual table entries in parallel unless they’re wrapped in subtests.
You do this by calling t.Parallel() inside the subtest’s closure.
func TestHttpStatusChecker(t *testing.T) {
tests := []struct {
name string
url string
want int
}{
{"google", "https://google.com", 200},
{"github", "https://github.com", 200},
{"nonexistent", "https://thisdomain.doesnotexist", -1},
}
for _, tt := range tests {
tt := tt // CRITICAL: Capture the loop variable!
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
time.Sleep(1 * time.Second) // Simulate a slow network call
got, err := CheckStatus(tt.url)
if err != nil && tt.want != -1 {
t.Fatalf("CheckStatus(%q) failed with error: %v", tt.url, err)
}
if got != tt.want {
t.Errorf("CheckStatus(%q) = %d, want %d", tt.url, got, tt.want)
}
})
}
}
Run this with go test -v and it will take roughly 1 second, not 3. The test runner queues up all the subtests marked as parallel, and then runs them concurrently. It’s an easy way to slash the execution time of your integration test suite.
Did you see that comment screaming at you to capture the loop variable? That’s the single biggest gotcha. Let’s talk about it.
The Infamous Loop Variable Gotcha
This is the rite of passage for every Go programmer. In Go, the tt variable in our loop is reused for every iteration. When you fire off a goroutine (which t.Parallel() does under the hood), the closure captures a reference to that variable, not its value at the time the subtest was defined. By the time the subtest actually runs, the loop has likely finished, and tt now points to the last element in your slice. So all your parallel subtests will inexplicably run with the last test case’s data. It’s a nightmare.
The fix is simple, elegant, and utterly mandatory: always shadow the loop variable inside the loop.
for _, tt := range tests {
tt := tt // This creates a new, local `tt` for each iteration.
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
// Now this closure has its own `tt` that won't change.
})
}
This line (tt := tt) is the difference between a working test suite and a heisenbug factory. Don’t forget it.
Controlling the Parallelism Circus
Running 1000 integration tests in parallel might bring your database to its knees. The -parallel flag is your lever to control this chaos. go test -parallel 4 allows a maximum of 4 tests to run concurrently. It defaults to the value of GOMAXPROCS, which is usually your CPU core count. This is sensible for CPU-bound unit tests but often needs to be cranked up for I/O-bound tests where goroutines are just waiting around.
Nesting: For When You Need More Hierarchy
You can also nest t.Run() calls. This is useful for creating a logical hierarchy in your tests, though I recommend using it sparingly to avoid creating a maze.
t.Run("Authentication", func(t *testing.T) {
t.Run("ValidCredentials", func(t *testing.T) { ... })
t.Run("InvalidCredentials", func(t *testing.T) { ... })
})
You can then run all authentication tests with -run "TestX/Authentication/" or just the invalid credentials test with -run "TestX/Authentication/InvalidCredentials".
So, stop treating your table tests like a monolith. Wrap them in t.Run(), give them proper names, and run them in parallel. Your future self, staring at a failing test at 2 AM, will thank you for it.