Table-Driven Tests in Go: The Idiomatic Pattern

Table-Driven Tests in Go: The Idiomatic Pattern

Table-driven tests are Go's idiomatic approach to testing multiple input/output combinations. Instead of writing one test function per case, you define all cases as a slice of structs and loop through them. The Go standard library itself uses this pattern extensively.

The Basic Pattern

func TestAdd(t *testing.T) {
    tests := []struct {
        name string
        a, b int
        want int
    }{
        {"two positives", 2, 3, 5},
        {"with zero", 0, 5, 5},
        {"two negatives", -2, -3, -5},
        {"opposite signs", 5, -3, 2},
    }

    for _, tc := range tests {
        t.Run(tc.name, func(t *testing.T) {
            got := Add(tc.a, tc.b)
            if got != tc.want {
                t.Errorf("Add(%d, %d) = %d; want %d", tc.a, tc.b, got, tc.want)
            }
        })
    }
}

Each case has a name that becomes the subtest name in output. When a test fails, you see exactly which case:

--- FAIL: TestAdd (0.00s)
    --- FAIL: TestAdd/two_negatives (0.00s)
        add_test.go:18: Add(-2, -3) = -6; want -5

Why Table-Driven?

Before: One test function per case.

func TestAdd_TwoPositives(t *testing.T) {
    if Add(2, 3) != 5 {
        t.Error("wrong")
    }
}
func TestAdd_WithZero(t *testing.T) {
    if Add(0, 5) != 5 {
        t.Error("wrong")
    }
}
func TestAdd_TwoNegatives(t *testing.T) {
    if Add(-2, -3) != -5 {
        t.Error("wrong")
    }
}

After: One table, one loop.

tests := []struct{ name string; a, b, want int }{
    {"two positives", 2, 3, 5},
    {"with zero", 0, 5, 5},
    {"two negatives", -2, -3, -5},
}
for _, tc := range tests {
    t.Run(tc.name, func(t *testing.T) {
        if got := Add(tc.a, tc.b); got != tc.want {
            t.Errorf("got %d, want %d", got, tc.want)
        }
    })
}

Adding a new case is one line. The pattern scales to 50 cases without complexity creep.

Struct Design

Design your test struct for readability:

tests := []struct {
    name    string
    input   string
    want    int
    wantErr bool
}{
    {
        name:  "valid integer",
        input: "42",
        want:  42,
    },
    {
        name:    "empty string",
        input:   "",
        wantErr: true,
    },
    {
        name:    "non-numeric",
        input:   "abc",
        wantErr: true,
    },
}

Use wantErr bool for error cases, not a specific error message — unless you specifically want to assert the error content.

Testing Errors

func TestParseInt(t *testing.T) {
    tests := []struct {
        name    string
        input   string
        want    int
        wantErr bool
    }{
        {"valid", "42", 42, false},
        {"negative", "-7", -7, false},
        {"empty", "", 0, true},
        {"letters", "abc", 0, true},
        {"overflow", "99999999999999999999", 0, true},
    }

    for _, tc := range tests {
        t.Run(tc.name, func(t *testing.T) {
            got, err := ParseInt(tc.input)
            if (err != nil) != tc.wantErr {
                t.Errorf("ParseInt(%q) error = %v, wantErr %v", tc.input, err, tc.wantErr)
                return
            }
            if !tc.wantErr && got != tc.want {
                t.Errorf("ParseInt(%q) = %d, want %d", tc.input, got, tc.want)
            }
        })
    }
}

The (err != nil) != tc.wantErr pattern is idiomatic — it's true when you got an error but didn't want one, or got nil but did want an error.

Testing with Specific Error Types

When you want to assert a specific error:

tests := []struct {
    name    string
    input   string
    wantErr error
}{
    {"not found", "user:99", ErrNotFound},
    {"invalid format", "bad-input", ErrInvalidFormat},
    {"valid", "user:1", nil},
}

for _, tc := range tests {
    t.Run(tc.name, func(t *testing.T) {
        _, err := Fetch(tc.input)
        if !errors.Is(err, tc.wantErr) {
            t.Errorf("Fetch(%q) error = %v, want %v", tc.input, err, tc.wantErr)
        }
    })
}

Parallel Table Tests

Run subtests in parallel for speed:

for _, tc := range tests {
    tc := tc  // capture range variable (required before Go 1.22)
    t.Run(tc.name, func(t *testing.T) {
        t.Parallel()
        got := Process(tc.input)
        if got != tc.want {
            t.Errorf("got %v, want %v", got, tc.want)
        }
    })
}

t.Parallel() inside t.Run makes subtests run concurrently. The parent test waits for all subtests to finish. This is safe when test cases don't share mutable state.

In Go 1.22+, loop variables are properly scoped per iteration, so tc := tc is no longer needed.

Setup per Test Case

Some cases need per-case setup:

tests := []struct {
    name   string
    setup  func(t *testing.T) *DB
    query  string
    want   []User
}{
    {
        name: "returns matching users",
        setup: func(t *testing.T) *DB {
            db := newTestDB(t)
            db.Insert(User{Name: "Alice", Active: true})
            db.Insert(User{Name: "Bob", Active: false})
            return db
        },
        query: "active=true",
        want:  []User{{Name: "Alice", Active: true}},
    },
    {
        name: "returns empty when no match",
        setup: func(t *testing.T) *DB {
            return newTestDB(t)
        },
        query: "active=true",
        want:  []User{},
    },
}

for _, tc := range tests {
    t.Run(tc.name, func(t *testing.T) {
        db := tc.setup(t)
        got := db.Query(tc.query)
        if !reflect.DeepEqual(got, tc.want) {
            t.Errorf("got %v, want %v", got, tc.want)
        }
    })
}

The setup function receives *testing.T so it can register cleanup with t.Cleanup.

Golden Files

For large expected outputs, use golden files:

func TestRender(t *testing.T) {
    tests := []struct {
        name     string
        template string
        data     map[string]string
    }{
        {"simple", "Hello, {{.Name}}!", map[string]string{"Name": "Alice"}},
        {"empty", "No data", nil},
    }

    for _, tc := range tests {
        t.Run(tc.name, func(t *testing.T) {
            got := Render(tc.template, tc.data)

            goldenFile := filepath.Join("testdata", tc.name+".golden")
            if *updateGolden {
                os.WriteFile(goldenFile, []byte(got), 0644)
                return
            }

            want, _ := os.ReadFile(goldenFile)
            if got != string(want) {
                t.Errorf("output mismatch:\ngot:  %q\nwant: %q", got, string(want))
            }
        })
    }
}

Add a flag: var updateGolden = flag.Bool("update", false, "update golden files")

Update golden files: go test -run TestRender -update

Naming Conventions

Good subtest names:

  • "valid email address" — describes input
  • "empty input returns error" — describes input and expected behavior
  • "authenticated user can delete" — describes precondition and action

Bad names:

  • "test1", "case2" — meaningless
  • "success", "failure" — too vague for debugging

Go converts spaces to underscores in subtest names for filtering:

go test -run TestParse/valid_email_address ./...

When Not to Use Tables

Table-driven tests work best when test cases share the same structure. Don't force them when:

  • Cases require very different setup
  • The test logic differs significantly between cases
  • You have only 1-2 cases (just write them directly)

Forcing a table where the structure doesn't fit produces awkward code. Use t.Run subtests directly for complex, divergent cases:

func TestComplexFlow(t *testing.T) {
    t.Run("creates order and sends email", func(t *testing.T) {
        // complex setup unique to this case
    })

    t.Run("rolls back on payment failure", func(t *testing.T) {
        // different complex setup
    })
}

Running Table Tests

# Run all tests
go <span class="hljs-built_in">test ./...

<span class="hljs-comment"># Run a specific table test
go <span class="hljs-built_in">test -run TestAdd ./calculator/...

<span class="hljs-comment"># Run a specific case within the table
go <span class="hljs-built_in">test -run TestAdd/two_positives ./calculator/...

<span class="hljs-comment"># Run with verbose output to see all case names
go <span class="hljs-built_in">test -v -run TestAdd ./calculator/...

Table-driven tests are the Go way to cover edge cases systematically. Once you adopt this pattern, adding new test cases becomes a one-line change instead of a new function, and your test suite reads like a specification.

Read more