Posted on Aug 7 • Edited on Aug 9
Here is the updated article incorporating Swapnoneel’s excellent suggestion to explicitly show the two distinct failure modes, followed by a drafted reply to the comment.
Updated Article
title: “Your Go table test passes, but not for the reason you think”
published: true
description: “t.Parallel inside a table test defers the subtest until the loop is over. Before Go 1.22 that quietly tested the last case N times, but the underlying scheduling trap remains.”
tags: go, testing, concurrency, programming
This is the single most reproduced bug in Go test suites, and it is green the whole time it is wrong.
func TestValidate(t *testing.T) {
cases := []struct {
name string
in string
want bool
}{
{"empty", "", false},
{"valid", "abc", true},
{"too long", strings.Repeat("x", 300), false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
if got := Validate(tc.in); got != tc.want {
t.Errorf("got %v want %v", got, tc.want)
}
})
}
}
Enter fullscreen mode Exit fullscreen mode
What t.Parallel actually does
It does not immediately start a goroutine. It pauses the subtest and returns control to the parent. The parent keeps looping. Only when the parent’s function body finishes do the paused subtests resume and run together.
This scheduling rule creates two distinct traps. Go 1.22 fixed the first one, but the second one is baked into the test structure itself.
Failure 1: The Loop Capture Trap (Fixed in Go 1.22)
Because t.Parallel() defers execution, the for loop completes before any subtest actually runs.
In Go 1.21 and earlier, tc was a single variable reused across iterations. By the time the subtests resumed, the loop was over, and they all read the final value of tc:
--- PASS: TestValidate/empty (actually ran "too long")
--- PASS: TestValidate/valid (actually ran "too long")
--- PASS: TestValidate/too_long
Enter fullscreen mode Exit fullscreen mode
Three passes. One case tested. The names in the output are correct — they were captured by t.Run before the pause — which is what makes it so convincing. (The cheapest way to confirm this is to deliberately break one case like {"valid", "abc", false}; if zero or three tests fail instead of exactly one, your loop is capturing incorrectly).
The Go 1.22 Fix:
As of Go 1.22, loop variables are per-iteration. Each tc is a distinct variable, so the capture is correct and the old tc := tc shadowing line is no longer needed.
(Note: The new semantics apply only when the module’s go directive in go.mod says go 1.22 or later. Bumping your toolchain is not enough).
Failure 2: The Early Cleanup Trap (Still broken in 1.22+)
Per-iteration variables fixed the capture bug, but they did not change when parallel subtests run. Anything at the end of your parent test still executes before the subtests resume.
If we add a database or file closure to our test, it will fail (or panic), even in Go 1.22:
func TestWithDatabase(t *testing.T) {
db := ConnectDB()
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
db.Query(tc.in) // PANIC! db is already closed.
})
}
db.Close() // Runs BEFORE any subtest body!
// (Using `defer db.Close()` at the top of the function does the exact same thing)
}
Enter fullscreen mode Exit fullscreen mode
Because t.Parallel() means “resume me after my parent returns”, the parent finishes the loop, calls db.Close(), returns, and then the subtests try to query the closed database.
The Fix:
The standard fix is to use t.Cleanup(), which registers a callback that strictly waits until all parallel children have finished:
func TestWithDatabase(t *testing.T) {
db := ConnectDB()
t.Cleanup(func() { db.Close() }) // Safe: waits for subtests
for _, tc := range cases {
// ...
}
}
Enter fullscreen mode Exit fullscreen mode
The takeaway
t.Parallel()means “resume me after my parent returns”. Every surprise it causes follows from that one sentence.
These posts come out of material I build for my Udemy courses — 25 of them now, mostly drill-based, across Go, Python, TypeScript, testing and Three.js. If this was useful, the full list is at udemy-c1f90.web.app. The links on that page carry a coupon I refresh each month, which usually lands around half the list price.
답글 남기기