DEV Community

MSakai
MSakai

Posted on

Your Go table test passes, but not for the reason you think

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 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.

So the loop completes first, and every subtest resumes afterwards. In Go 1.21 and earlier, tc was a single variable reused across iterations, so all three subtests resumed reading the same, final value:

--- 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.

Go 1.22 changed the underlying cause

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:

for _, tc := range cases {
    tc := tc   // no longer necessary in Go 1.22+
    t.Run(...)
}
Enter fullscreen mode Exit fullscreen mode

Two things worth knowing about that:

  • 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 — a module still declaring go 1.21 keeps the old behaviour.
  • go vet has caught this pattern (loopclosure) for a while, but only in the shapes it recognises. It is not a guarantee.

Verify it rather than trust it

The cheapest way to confirm your table tests are actually running distinct cases is to break one on purpose:

{"valid", "abc", false},   // deliberately wrong
Enter fullscreen mode Exit fullscreen mode

Exactly one subtest should go red. If zero or three do, your loop is not doing what you think.

The ordering trap that survives the 1.22 fix

Per-iteration variables fixed the capture. They did not change when parallel subtests run. Anything after the loop still executes before them:

for _, tc := range cases {
    t.Run(tc.name, func(t *testing.T) { t.Parallel(); /* ... */ })
}
db.Close()   // runs before any subtest body
Enter fullscreen mode Exit fullscreen mode

The standard fix is to wrap the loop in a non-parallel parent, or use t.Cleanup, which runs after parallel children have finished:

t.Cleanup(func() { db.Close() })
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.

Top comments (1)

Collapse
 
swapnoneel123 profile image
Swapnoneel Saha

the distinction between the loop variable fix and the scheduling rule is important. a useful follow up would show one test that fails because of capture and another that fails because cleanup runs too early. that separation would help readers see which problem go 1.22 solved and which problem still belongs to the test structure.