Go 테이블 테스트는 통과했지만, 생각하시는 이유로 통과하지 못했습니다.

작성자

카테고리:

← 피드로
DEV Community · MSakai · 2026-08-07 개발(SW)

MSakai

MSakai

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.

원문에서 계속 ↗

코멘트

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다